FastHTML builds web applications entirely in Python: HTML elements are Python function calls, routing is Python decorators, and interactivity is HTMX attribute additions — no JavaScript framework, no build step, no transpilation. Div, Button, Form, Input are functions returning HTML strings. Routes return FT (FastTag) elements directly. hx-post, hx-target, and hx-swap drive server-side updates over HTTP. Datastar extends HTMX’s reactivity model. Claude Code generates FastHTML route handlers, component functions, form submissions with HTMX, and the server-sent event patterns for real-time Python web applications.
CLAUDE.md for FastHTML Projects
## FastHTML Stack
- Version: python-fasthtml >= 0.12
- UI components: FT elements (Div, P, Button, Form, Input, etc.) — all Python
- Interactivity: HTMX attributes (hx-get, hx-post, hx-target, hx-swap)
- Styling: PicoCSS (built-in) or Tailwind CDN — no build step
- DB: SQLite via sqlite-utils or fastlite for zero-config persistence
- Forms: Handle as dicts in route handlers, validate with Pydantic if needed
- Sessions: Starlette sessions via middleware for auth state
- SSE: server-sent events for live updates with EventStream
Basic App Structure
# main.py — FastHTML app with routes and components
from fasthtml.common import *
from datetime import datetime
app, rt = fast_app(
hdrs=(
Link(rel="stylesheet", href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"),
),
live=True, # Enable live reload in development
)
# In-memory store (replace with DB)
orders = {}
# Component: Python function returning FT elements
def order_card(order: dict) -> FT:
status_color = {
"pending": "secondary",
"processing": "primary",
"shipped": "contrast",
"delivered": "success",
"cancelled": "danger",
}.get(order["status"], "secondary")
return Article(
Header(
Span(f"#{order['id'][-6:]}", cls="mono"),
Span(f"${order['amount'] / 100:.2f}", style="float:right; font-weight:bold"),
),
P(f"Customer: {order['customer_id']}"),
Footer(
Mark(order["status"], cls=f"pico-background-{status_color}"),
# HTMX: cancel button sends POST without full page reload
*(
[Button(
"Cancel",
hx_post=f"/orders/{order['id']}/cancel",
hx_target=f"#order-{order['id']}",
hx_swap="outerHTML",
hx_confirm="Cancel this order?",
cls="secondary small",
)]
if order["status"] == "pending" else []
),
),
id=f"order-{order['id']}",
)
def page_layout(*content, title="Orders") -> FT:
return Html(
Head(
Title(title),
Script(src="https://unpkg.com/[email protected]"),
*app.hdrs,
),
Body(
Main(
H1(title),
*content,
cls="container",
)
),
)
# Routes with FT return values
@rt("/")
def get():
order_list = [order_card(o) for o in orders.values()]
return page_layout(
Div(
H2("Your Orders"),
*order_list if order_list else [P("No orders yet.", cls="secondary")],
id="order-list",
),
A("+ New Order", href="/orders/new", role="button"),
)
@rt("/orders/new")
def get():
return page_layout(
H2("New Order"),
Form(
Label("Customer ID",
Input(type="text", name="customer_id", placeholder="cust-123", required=True)
),
Label("Amount (dollars)",
Input(type="number", name="amount", min="1", step="0.01", required=True)
),
Button("Place Order", type="submit"),
method="post",
action="/orders",
),
)
@rt("/orders")
def post(customer_id: str, amount: float):
"""Handle order creation form submission."""
import uuid
order_id = str(uuid.uuid4())
orders[order_id] = {
"id": order_id,
"customer_id": customer_id,
"amount": int(amount * 100), # Store as cents
"status": "pending",
"created_at": datetime.utcnow().isoformat(),
}
return RedirectResponse("/", status_code=303)
@rt("/orders/{order_id}/cancel")
def post(order_id: str):
"""HTMX partial update: cancel order, return updated card."""
if order_id not in orders:
return Div("Order not found", cls="error", id=f"order-{order_id}")
orders[order_id]["status"] = "cancelled"
# Return just the updated card — HTMX swaps it in
return order_card(orders[order_id])
Database with fastlite
# db.py — SQLite persistence with fastlite
from fasthtml.common import *
from fastlite import database
from datetime import datetime
db = database("orders.db")
# Define table schema
orders = db.t.orders
if not orders.exists():
orders.create({
"id": str,
"customer_id": str,
"amount": int,
"status": str,
"created_at": str,
}, pk="id")
Order = orders.dataclass()
@rt("/orders")
def get():
"""List orders from SQLite."""
all_orders = orders(order_by="created_at desc", limit=50)
return page_layout(
Div(
*(order_card(o.__dict__) for o in all_orders),
id="order-list",
),
)
@rt("/orders")
def post(customer_id: str, amount: float):
import uuid
order = Order(
id=str(uuid.uuid4()),
customer_id=customer_id,
amount=int(amount * 100),
status="pending",
created_at=datetime.utcnow().isoformat(),
)
orders.insert(order)
return RedirectResponse("/", status_code=303)
HTMX Live Search
# Search with HTMX infinite scroll and debounce
@rt("/orders/search")
def get(q: str = "", page: int = 0):
"""HTMX partial: returns matching order cards."""
page_size = 20
results = orders(
where="customer_id LIKE ? OR id LIKE ?",
where_args=[f"%{q}%", f"%{q}%"],
limit=page_size + 1,
offset=page * page_size,
)
has_more = len(results) > page_size
display = results[:page_size]
cards = [order_card(o.__dict__) for o in display]
if has_more:
cards.append(
Div(
# Infinite scroll trigger: loads next page when visible
hx_get=f"/orders/search?q={q}&page={page + 1}",
hx_trigger="revealed",
hx_swap="outerHTML",
cls="loading-sentinel",
)
)
return Div(*cards, id=f"search-page-{page}")
# Route for search input with debounce
def search_bar() -> FT:
return Input(
type="search",
name="q",
placeholder="Search orders...",
# hx-get triggers search on keystroke (debounced)
hx_get="/orders/search",
hx_target="#search-results",
hx_trigger="keyup changed delay:300ms",
hx_swap="innerHTML",
hx_push_url="false",
)
Server-Sent Events for Real-Time
# SSE: push updates to connected clients
from starlette.responses import EventSourceResponse
import asyncio
@rt("/events/orders")
async def get(request):
"""Stream order updates via SSE."""
async def event_generator():
last_count = 0
while True:
current_count = orders.count
if current_count != last_count:
# New order: send updated count badge
badge_html = str(Span(str(current_count), id="order-count", cls="badge"))
yield {"data": badge_html, "event": "order-update"}
last_count = current_count
await asyncio.sleep(2)
# Check if client disconnected
if await request.is_disconnected():
break
return EventSourceResponse(event_generator())
# Client-side: listen for SSE with HTMX
def order_count_badge() -> FT:
return Div(
Span(str(orders.count), id="order-count", cls="badge"),
# HTMX SSE extension: swap content on server events
hx_ext="sse",
sse_connect="/events/orders",
sse_swap="order-update",
hx_target="#order-count",
hx_swap="outerHTML",
)
serve() # Start the app
For the Django full-stack framework with ORM, admin, and batteries-included Python web development without FastHTML’s simplicity tradeoffs, see the patterns in the Django REST framework guide. For the htmx-alpine.js pattern that uses HTMX from a traditional HTML template system with Alpine for lightweight client state, the HTMX guide covers Alpine.js integration. The Claude Skills 360 bundle includes FastHTML skill sets covering component patterns, HTMX interactivity, and database integration. Start with the free tier to try FastHTML app generation.