Python-first, zero-runtime fullstack web framework.
Write reactive single-page applications entirely in Python. RailUI compiles your Python DSL directly into vanilla JavaScript/HTML/CSS bundles no Node.js, no JS framework, no runtime interpreter.
from railui.all import *
count, setCount = createSignal(0)
app = App("My App")
@app.route("/")
def home() -> Component:
return Container(
Text("Count: ", count()),
Button("+1", on_click=setCount(count() + 1)),
class_name="flex gap-4 p-6",
)
if __name__ == "__main__":
app.build()| Pure Python DSL | Operator overloading compiles count() + 1 → sig_1() + 1 in JS — no Python runtime needed |
| Reactive Signals | First-class createSignal, createStore, useComputed, createEffect |
| Server Actions | @server_action decorator — call Python functions from the browser via auto-generated fetch() |
| File-Based Routing | Drop a .py file in pages/, get a route. Supports dynamic [id] segments |
| SPA Router | History API client-side navigation with scoped effects and lifecycle |
| Component System | 15+ built-in components: Container, Button, Input, Show, Each, Form, Link, Image, Head, Suspense, slots, and more |
| Two-Way Binding | bind=signal on inputs — reactive form handling in one line |
| Animations | 12 pre-built WAAPI animations: fade_in, slide_in_up, spin, bounce, shake, pulse + custom keyframes |
| Conditional Classes | class_list={"bg-red-500": has_error()} |
| Tailwind-like CSS | 150+ utility classes compiled at build time — zero runtime CSS |
| JS Bridge | 30+ utilities: log, alert, set_timeout, navigate, local_storage_set, copy_to_clipboard, on_mount, on_destroy |
| JS Namespace Wrappers | Math.floor(x), JSON.stringify(), Object.keys(), document.getElementById() — all compile to native JS |
| Named Slots | Slot/SlotFill for composable layouts |
| HMR | Dev server with file watcher and SSE-based hot reload |
| Zero Runtime | Output is pure vanilla JS (~1KB runtime) — no framework shipped to the browser |
| Deploy Anywhere | Railway (full-stack with server actions), Vercel (static SPA), or any static host |
pip install railuiRequires Python ≥ 3.12.
railui new my-app
cd my-appOr manually create this structure:
my-app/
main.py # App entry point
layout.py # Shared layout (optional)
pages/
index.py # Route: /
counter.py # Route: /counter
public/ # Static assets (copied to build)
railui.config.json
# pages/counter.py
from railui.all import *
def page() -> Component:
count, setCount = createSignal(0)
return Container(
Text("Count: ", count(), class_name="text-2xl font-bold"),
Button("+1", on_click=setCount(count() + 1), class_name="px-4 py-2 bg-blue-600 text-white rounded"),
)# main.py
from railui.all import *
app = App("My Counter App")
app.discover_pages("pages")
if __name__ == "__main__":
app.build()railui build
# Output: dist/index.html + app.{hash}.js + app.{hash}.cssrailui dev
# Opens http://127.0.0.1:5173 — HMR enabledRailUI overloads Python operators so expressions like count() + 1 produce JavaScript AST nodes instead of executing immediately. This is how you write reactive logic in pure Python.
count, setCount = createSignal(10)
# These compile to JavaScript, they don't run in Python:
count() + 1 # → sig_1() + 1
count() * 2 # → sig_1() * 2
count() > 5 # → sig_1() > 5
count() == 0 # → sig_1() === 0 (=== in JS)
~show_details() # → !sig_2()
count() + 1 * 2 # → (sig_1() + (1 * 2))
# Property access uses optional chaining:
user_data().name # → sig_3()?.name
user_data().address.city # → sig_3()?.address?.city
# Index access:
items()[0] # → sig_4()?.[0]When you need raw JS, use RawJS:
RawJS("console.log('hello')")# Simple signal
name, setName = createSignal("Alice")
age, setAge = createSignal(30)
# Reading (inside component tree — compiles to JS):
Text(name()) # reactive text node
Text(age() + 1) # reactive expression
# Writing (inside event handlers — compiles to JS):
Button("Older", on_click=setAge(age() + 1))
# Computed value:
double_age = useComputed(lambda: age() * 2)
Text(double_age) # reactive computed
# Side effect on every signal change:
createEffect(log("age changed to: ", age()))
# Store (grouped signals):
user = createStore({"name": "Alice", "role": "admin"})
Text(user.name()) # reactive getter
Button("Reset", on_click=user.set_name("")) # setter
Input(bind=user.name) # two-way bindingContainer(
Text("Hello", class_name="text-xl font-bold"),
Button("Click", on_click=log("clicked"), class_name="px-4 py-2 bg-blue-500 text-white rounded"),
class_name="flex flex-col gap-4 p-6",
id="main-container",
)| File | Route |
|---|---|
pages/index.py |
/ |
pages/dashboard.py |
/dashboard |
pages/blog/index.py |
/blog |
pages/users/[id].py |
/users/:id |
Each page module must export a page() function returning a Component:
# pages/about.py
from railui.all import *
def page() -> Component:
return Container(
Text("About Us", class_name="text-3xl font-bold"),
)app = App()
@app.route("/hello")
def hello_page() -> Component:
return Text("Hello!")
@app.route("/user/<name>")
def user_page(name: str) -> Component:
return Text(f"User: {name}")def not_found() -> Component:
return Container(
Text("404 — Page Not Found"),
Link("Go home", href="/"),
)
app.set_not_found(not_found)count, setCount = createSignal(0)
Text(count()) # reactive display
Button("+", on_click=setCount(count() + 1)) # reactive updateuser = createStore({
"name": "Alice",
"role": "admin",
"is_logged_in": True,
})
Text(user.name()) # → sig_1()
Input(bind=user.name) # two-way binding
Button("Logout", on_click=user.set_is_logged_in(False))posts, setPosts = createSignal([])
loading, setLoading = createSignal(True)
useFetch(
url="https://api.example.com/posts",
on_success=setPosts,
loading=setLoading,
on_error=log("Failed to load posts"),
)
Suspense(
Each(items=posts, render_fn=lambda post, i: Text(post.title)),
fallback=Text("Loading..."),
loading=loading(),
)createEffect(log("count changed to: ", count()))Button(
"Click me",
on_click=log("clicked!"),
on_dblclick=setCount(count() + 2),
on_mouseenter=log("hover!"),
)
Input(
on_input=setName(event_value()), # event.target.value
on_change=log("committed: ", name()),
on_focus=log("focused"),
on_blur=log("blurred"),
)
Form(
on_submit=runSequence(prevent_default(), log("submitted")),
)username, setUsername = createSignal("")
Input(
bind=username, # two-way binding — one line
placeholder="Enter username...",
class_name="px-4 py-2 border rounded",
)prevent_default() # → event.preventDefault()
stop_propagation() # → event.stopPropagation()
event_value() # → event.target.valueis_visible, setVisible = createSignal(False)
Show(
Text("This is visible when the signal is truthy"),
when=is_visible(),
on_mount=log("BECAME VISIBLE"),
on_update=log("UPDATED"),
on_unmount=log("HIDDEN"),
)
# With fallback content:
Show(
Text("Content loaded!"),
when=is_loaded(),
fallback=Text("Loading...", class_name="text-gray-500"),
)todos, setTodos = createSignal([
{"title": "Learn RailUI"},
{"title": "Build something awesome"},
])
Each(
items=todos,
render_fn=lambda item, index: Container(
Text(index, class_name="text-gray-400 mr-2"),
Text(item.title, class_name="font-semibold"),
class_name="p-3 border-b",
),
on_mount=log("List mounted!"),
on_update=log("List updated!"),
)
# Immutable array operations:
Button("Add", on_click=setTodos(Array.append(todos(), {"title": "New task"})))
Button("Remove", on_click=setTodos(Array.remove(todos(), 0)))Call Python functions from the browser — no API boilerplate.
# pages/users.py
from railui.all import *
@server_action
def save_user(name: str):
print(f"Saving {name} to database...")
return {"status": "ok", "message": f"User {name} saved!"}
def page() -> Component:
username, setUsername = createSignal("")
status_msg, setStatusMsg = createSignal("")
return Container(
Input(bind=username, placeholder="Enter name..."),
Button(
"Save",
on_click=save_user(username()).then(lambda res: setStatusMsg(res.message)),
),
Show(Text(status_msg()), when=status_msg() != ""),
)@server_actionregisters the Python function in_ACTION_REGISTRYsave_user(username())generates aServerActionCallAST node.then(lambda res: setStatusMsg(res.message))chains a Promise handler- Compiled JS:
fetch('/_railui_action/save_user', {method:'POST', body:JSON.stringify([sig_1()])}).then(r=>r.json()).then(res=>{ set_sig_2(res.message) }) - The dev server (or production FastAPI server) receives the POST, deserializes the args, and calls the registered Python function
Server actions require a Python backend. Deploy to:
- Railway (recommended) —
railui build --platform railwaygenerates config - Any VPS — run
uvicorn railui.backend.server:create_app --factory - NOT Vercel — Vercel's serverless functions can't run a persistent Python process
on_mount(log("Page mounted!"))
on_destroy(log("Page destroyed — clean up timers!"))
# Alternatively inside Show/Each:
Show(
Text("Content"),
when=is_visible(),
on_mount=log("First time visible"),
on_update=log("Visibility changed"),
on_unmount=log("Now hidden"),
)12 pre-built Web Animations API helpers:
fade_in("element-id")
fade_out("element-id")
slide_in_left("element-id", distance="40px")
slide_in_right("element-id")
slide_in_up("element-id")
slide_out_down("element-id")
spin("element-id", duration=800, iterations="Infinity")
bounce("element-id", height="12px", duration=600)
pulse("element-id", duration=800)
shake("element-id", duration=400) # form errors
scale_in("element-id", from_scale=0.8)
scale_out("element-id", to_scale=0.8)Chain with runSequence:
runSequence(setCount(count() + 1), shake("counter-card"))
runSequence(setCount(0), fade_out("card"), set_timeout(slide_in_up("card"), 300))Custom keyframes:
animate("my-element", [
{"transform": "translateX(0px)", "opacity": 1},
{"transform": "translateX(100px)", "opacity": 0},
], duration=500, easing="ease-in-out")Define a layout with Slot placeholders:
# layout.py
from railui.all import *
def Layout(*children):
return Container(
Navbar(),
Slot("hero", source=children, default=""),
Container(
Slot("body", source=children, default=Slot.Unassigned(children)),
),
Slot("footer", source=children,
default=Text("© 2026", class_name="text-gray-500")),
class_name="min-h-screen bg-gray-50",
)Fill slots from pages:
# pages/index.py
def page():
return Layout(
SlotFill("hero", Container(
Text("Welcome!", class_name="text-3xl font-bold text-white"),
class_name="bg-gradient-to-r from-purple-600 to-blue-500 p-12",
)),
Container(
Text("Main content here"),
class_name="p-8",
),
SlotFill("footer", Container(
Text("Custom footer"),
)),
)Slot.Unassigned(children) returns all children that are not SlotFill instances — perfect for default body content.
Head(
title="Dashboard | My App",
meta={"description": "Dashboard page", "keywords": "python, web"},
styles=["https://cdn.example.com/styles.css"],
scripts=["https://cdn.example.com/widget.js"],
)Suspense(
Container(
Text(user_data().name),
Text(user_data().email),
),
fallback=Container(
Text("Loading...", class_name="animate-pulse"),
),
loading=loading(), # boolean signal from useFetch
)Form(
Container(
Label("Name", for_="name-input"),
Input(
bind=user_store.name,
id="name-input",
class_name="w-full p-3 border rounded",
),
),
Container(
Label("Role", for_="role-input"),
Input(
bind=user_store.role,
id="role-input",
),
),
Button("Save", type="submit",
on_click=log("Saved:", user_store.name(), user_store.role())),
class_name="flex flex-col gap-4 max-w-md",
)Math.floor(count() / 2)
Math.random()
Math.max(count(), 0)
JSON.stringify(data())
JSON.parse(text())
Object.keys(user_data())
Object.entries(user_data())
String(count())
Number("42")
document.getElementById("my-el")
window.alert("Hello!")
Array.append(tasks(), new_task)
Array.prepend(items(), "first")
Array.remove(items(), index)
Array.concat(list_a(), list_b())log("message") # console.log
alert("Hello!") # window.alert
set_timeout(log("done"), 1000) # setTimeout
set_interval(log("tick"), 500) # setInterval
navigate("/dashboard") # SPA navigation
go_back()
reload()
focus_element("input-id")
scroll_to_element("section-2")
add_class("el", "highlight")
remove_class("el", "highlight")
toggle_class("el", "active")
set_inner_text("output", result())
set_inner_html("container", "<p>HTML</p>")
set_value("input", "new value")
get_value("input")
show_element("loading-spinner")
hide_element("loading-spinner")
local_storage_set("key", data())
local_storage_get("key")
local_storage_remove("key")
local_storage_clear()
copy_to_clipboard(text())Container(
Text("Error message"),
class_list={
"bg-red-100 text-red-800": has_error(),
"bg-green-100 text-green-800": ~has_error(),
},
)Button(
"Hover me",
class_name="px-4 py-2 bg-blue-600 text-white rounded",
hover_class="bg-blue-700 shadow-lg",
active_class="bg-blue-800 scale-95",
)RailUI compiles Tailwind-like class names to CSS at build time. 150+ utilities supported:
| Category | Examples |
|---|---|
| Layout | flex, grid, block, hidden, flex-row, flex-col, items-center, justify-between |
| Grid | grid-cols-3, grid-rows-2, col-span-2, gap-4 |
| Sizing | w-full, w-64, h-32, max-w-md, min-h-screen |
| Spacing | p-4, px-6, py-2, m-2, mx-auto, mt-8, -ml-2 |
| Typography | text-sm, text-lg, text-4xl, font-bold, font-mono, text-center, text-red-500 |
| Colors | bg-blue-600, text-white, border-gray-200, bg-gradient-to-r |
| Borders | border, border-2, border-t, rounded, rounded-lg, rounded-full |
| Shadows | shadow, shadow-sm, shadow-md, shadow-lg |
| Position | static, fixed, absolute, relative, sticky, z-50, top-0 |
| Effects | opacity-50, transition, outline-none |
| Pseudo | hover:bg-blue-700, focus:outline-none, focus:ring-2 |
| Custom | w-[100px], text-[#ff6600], bg-[#1a1a2e] |
railui dev [path] [--host HOST] [--port PORT] [--no-open] [--platform vercel]
Starts the dev server with HMR. Builds into .railui/dev/ — your project stays clean.
- File watcher rebuilds on
.pychanges - SSE pushes
reloadevent to browser - Serves from
.railui/dev/with SPA fallback
railui build [path] [--outdir DIR] [--no-bundle] [--platform railway|vercel]
Production build. Generates:
index.html— SPA shellapp.{hash}.js— Minified JS bundleapp.{hash}.css— Compiled Tailwind CSS- Deployment configs (if
--platformis set)
railui new <project-name> [--dir PARENT_DIR]
Scaffolds a new project with main.py, layout.py, pages/index.py, and config.
{
"outdir": "build",
"port": 5173,
"open_browser": true,
"bundle": true,
"platform": "railway",
"public_dirs": ["public", "assets"]
}railui.config.json in your project root. CLI flags override config values.
railui build --platform railway
railway upRailway runs the FastAPI backend via uvicorn. Server actions work out of the box.
The deployment templates (railway.toml, Procfile, requirements.txt) are auto-generated.
railui build --platform vercel
vercel --prodVercel cannot run Python server actions. The SPA itself works perfectly (routing, signals, animations, etc.), but @server_action calls return 404.
Use Vercel for:
- Marketing sites
- Dashboards with client-only data (via
useFetchon public APIs) - Prototypes
Just upload the dist/ directory after railui build.
FROM python:3.11-slim
WORKDIR /app
COPY . .
RUN pip install railui && railui build
CMD ["uvicorn", "railui.backend.server:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]Python AST (DSL) Compile Time Browser (Runtime)
───────────────── ────────────────── ──────────────────────
count + 1 → sig_1() + 1 → $signals.sig_1 + 1
setCount(42) → set_sig_1(42) → $signals.sig_1 = 42
↓
Component.render() → HTML + Effects → runEffects()
↓
createSignal(0) → createSignal(...) → DOM updates
↓
@server_action fetch(...).then(...) → FastAPI RPC
| Decision | Why |
|---|---|
| Compile-time DSL | Zero runtime overhead — no interpreter shipped to browser |
| Direct DOM manipulation | No virtual DOM — signals update elements directly via innerText, classList, etc. |
| Push-based signals | setSignal(val) triggers runEffects() — simple, deterministic |
| Pre-compiled routes | All route HTML is inlined in JS — no dynamic server rendering |
| Tailwind-like CSS | Regex-based compiler — 150+ rules, zero runtime CSS-in-JS |
| FastAPI backend | Async Python server for dev, HMR, and server actions |
| ~1KB runtime JS | Minimal client — faster loads, less to debug |
| Symbol | Description |
|---|---|
DSLExpr |
Base class for all DSL expressions |
RawJS(js) |
Inject raw JavaScript string |
to_dsl(val) |
Convert Python value to DSLExpr |
| Symbol | Description |
|---|---|
createSignal(initial) → (getter, setter) |
Create a reactive signal |
createStore(dict) → Store |
Create grouped signals |
useComputed(fn) → DSLExpr |
Derived reactive expression |
createEffect(expr) |
Register side effect |
useFetch(url, on_success, ...) |
Async data fetching |
| Symbol | Description |
|---|---|
App(title) |
Application instance |
app.route(path) |
Decorator — register route |
app.discover_pages(dir) |
File-based routing |
app.set_not_found(factory) |
404 page |
app.build(out_dir) |
Compile bundle |
| Component | Tag | Key Props |
|---|---|---|
Container |
<div> |
class_name, on_click, ... |
Text |
<span> |
class_name |
Button |
<button> |
type, disabled, on_click |
Input |
<input /> |
type, value, placeholder, bind, on_input |
Textarea |
<textarea> |
rows, cols, bind |
Select |
<select> |
name, bind, on_change |
Option |
<option> |
value, selected |
Label |
<label> |
for_ |
Form |
<form> |
action, method, on_submit |
Link |
<a> |
href (required), target |
Image |
<img /> |
src, alt (required) |
Page |
<main> |
semantic main element |
Show |
<div> |
when (required), fallback, lifecycle |
Each |
<div> |
items (required), render_fn (required), lifecycle |
Head |
— | title, meta, styles, scripts |
Suspense |
<div> |
fallback, loading |
Slot |
— | name, source, default |
SlotFill |
— | name |
| Symbol | Description |
|---|---|
@server_action |
Decorator — register server-side function |
ServerActionCall.then(callback) |
Chain Promise handler |
| Symbol | Description |
|---|---|
animate(id, keyframes, **opts) |
Custom WAAPI animation |
fade_in(id) / fade_out(id) |
Opacity fade |
slide_in_left/right/up(id) |
Slide + fade in |
slide_out_down(id) |
Slide out |
spin(id) |
Continuous rotation |
bounce(id) |
Vertical bounce |
pulse(id) |
Opacity pulse |
shake(id) |
Horizontal shake |
scale_in(id) / scale_out(id) |
Scale transition |
Math, JSON, Object, String, Number, Boolean, window, document, Array
log, warn, error, alert, confirm_dialog, set_timeout, set_interval, clear_interval, clear_timeout, navigate, go_back, go_forward, reload, open_url, focus_element, blur_element, click_element, scroll_to, scroll_to_element, scroll_to_top, set_attribute, remove_attribute, add_class, remove_class, toggle_class, set_inner_text, set_inner_html, set_value, get_value, set_style, show_element, hide_element, local_storage_set, local_storage_get, local_storage_remove, local_storage_clear, session_storage_set, session_storage_get, session_storage_remove, copy_to_clipboard, on_mount, on_destroy, runSequence, ifelse, typeof, event_value, prevent_default, stop_propagation
my-app/
├── main.py # App entry — App(), discover_pages()
├── layout.py # Shared layout with Slot system
├── store.py # Global state (createStore)
├── railui.config.json # Framework config
├── pages/
│ ├── index.py # Route: /
│ ├── dashboard.py # Route: /dashboard
│ └── users/
│ ├── index.py # Route: /users
│ └── [id].py # Route: /users/:id
├── public/ # Static assets
│ └── favicon.ico
└── dist/ # Build output (after `railui build`)
├── index.html
├── app.{hash}.js
└── app.{hash}.css
MIT