Python-first, zero-runtime fullstack web framework — compile Python DSL to vanilla JS/HTML/CSS SPAs
Project description
RailUI
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()
Features
| 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 |
Installation
pip install railui
Requires Python ≥ 3.12.
Quick Start
1. Create a project
railui new my-app
cd my-app
Or 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
2. Define a page
# 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"),
)
3. Wire it up
# main.py
from railui.all import *
app = App("My Counter App")
app.discover_pages("pages")
if __name__ == "__main__":
app.build()
4. Build
railui build
# Output: dist/index.html + app.{hash}.js + app.{hash}.css
5. Dev server
railui dev
# Opens http://127.0.0.1:5173 — HMR enabled
Core Concepts
The DSL — Python that becomes JavaScript
RailUI 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')")
Signals — Reactive State
# 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 binding
Components
Container(
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",
)
Routing
File-Based Routing
| 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"),
)
Programmatic Routing
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}")
404 Page
def not_found() -> Component:
return Container(
Text("404 — Page Not Found"),
Link("Go home", href="/"),
)
app.set_not_found(not_found)
State Management
createSignal
count, setCount = createSignal(0)
Text(count()) # reactive display
Button("+", on_click=setCount(count() + 1)) # reactive update
createStore
user = 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))
useFetch
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
createEffect(log("count changed to: ", count()))
Event Handling
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")),
)
Two-Way Binding
username, setUsername = createSignal("")
Input(
bind=username, # two-way binding — one line
placeholder="Enter username...",
class_name="px-4 py-2 border rounded",
)
Event Utility Functions
prevent_default() # → event.preventDefault()
stop_propagation() # → event.stopPropagation()
event_value() # → event.target.value
Conditional Rendering — Show
is_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"),
)
List Rendering — Each
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)))
Server Actions
Call Python functions from the browser — no API boilerplate.
Define
# 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() != ""),
)
How it works
@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
Deployment with Server Actions
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
Lifecycle Hooks
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"),
)
Animations
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")
Layout System — Named Slots
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 / Meta Tags (Per-Route)
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 & Async Loading
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 Handling
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",
)
JS Namespace Wrappers
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())
DOM Utilities
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())
Conditional Classes
Container(
Text("Error message"),
class_list={
"bg-red-100 text-red-800": has_error(),
"bg-green-100 text-green-800": ~has_error(),
},
)
Hover / Active Classes
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",
)
CSS / Tailwind-like Utilities
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] |
CLI Reference
railui dev
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
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
railui new <project-name> [--dir PARENT_DIR]
Scaffolds a new project with main.py, layout.py, pages/index.py, and config.
Configuration
{
"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.
Deployment
Railway (Full-Stack — Server Actions Supported)
railui build --platform railway
railway up
Railway runs the FastAPI backend via uvicorn. Server actions work out of the box.
The deployment templates (railway.toml, Procfile, requirements.txt) are auto-generated.
Vercel (Static SPA — No Server Actions)
railui build --platform vercel
vercel --prod
Vercel 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
Any Static Host
Just upload the dist/ directory after railui build.
Docker / Custom Server
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"]
Architecture Overview
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
Key Design Decisions
| 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 |
Full API Reference
Core Types
| Symbol | Description |
|---|---|
DSLExpr |
Base class for all DSL expressions |
RawJS(js) |
Inject raw JavaScript string |
to_dsl(val) |
Convert Python value to DSLExpr |
Signals & State
| 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 |
App
| 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 |
Components
| 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 |
Server Actions
| Symbol | Description |
|---|---|
@server_action |
Decorator — register server-side function |
ServerActionCall.then(callback) |
Chain Promise handler |
Animations
| 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 |
JS Namespaces
Math, JSON, Object, String, Number, Boolean, window, document, Array
Utility Functions
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
Project Structure
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
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file railui-0.1.0.tar.gz.
File metadata
- Download URL: railui-0.1.0.tar.gz
- Upload date:
- Size: 26.1 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fadbf11d272bb18743668907f89b81da34cf77a2549e1fcbbe9bbd2559b99e78
|
|
| MD5 |
b9ccd49e7270bef7a63112db86eb9e9f
|
|
| BLAKE2b-256 |
c803bb259c246772ac475051442607530a6074730b3fe087659c613987592fe7
|
File details
Details for the file railui-0.1.0-py3-none-any.whl.
File metadata
- Download URL: railui-0.1.0-py3-none-any.whl
- Upload date:
- Size: 27.2 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7b650b1e812fcfd5df8ff00fbe7b2a8cabe68b76d99fc97c089b8cad327b8c25
|
|
| MD5 |
1ca32f791f09c527c8275f813285f8e2
|
|
| BLAKE2b-256 |
fdfc6dd2c697c31bf7a2109460a04fb26781eabe70e5eaad66b9a62dc9137bd6
|