Realtime tiles
This is a walkthrough of the bundled tiles project. It is written to be fairly verbose on purpose: the goal is to show how core ideas for realtime apps come together in Stario with Datastar—shared state, one long-lived update channel, short commands over POST, and HTML pushed from Python.
If you have not worked through Getting started yet, skim it first—you should already be comfortable with bootstrap, Context, and Writer.
1. Create the project
The template ships CSS, a vendored Datastar script, and a layout so you can focus on behaviour instead of wiring assets from zero.
From the directory that should contain the new app:
Interactive wizard:
uvx stario@latest initChoose the tiles template when prompted, then cd into the folder the tool created.
Or non-interactive:
uvx stario@latest init my-tiles --template tilescd my-tilesIf you have used uvx stario before upgrading, uv may still run a cached copy of the tool even when you pass stario@latest. When the CLI version and what init writes into the project do not line up, run uvx --refresh stario once, then run init again.
Inside that project, uv already manages the environment. Run the app with:
uv run stario watch main:bootstrapOpen http://127.0.0.1:8000 (use --port if the default is busy).
2. Play with it first
Before opening main.py:
Open the grid in two browser tabs.
Paint cells in one tab—the other should update without a full reload.
What you are seeing: one normal page load (GET), then server-driven HTML updates on a long-lived connection (SSE), while clicks go out as short POSTs. That split is the pattern this tutorial unpacks.
Optionally keep the server terminal visible while you click—you will see request activity line up with what happens in the UI.
3. Commands vs queries (CQRS-shaped)
See The go-to architecture for the full picture. In short: queries answer “what should the UI show?” (HTML + SSE patches); commands apply user intent (POST routes that change state). This template wires that as different handlers, not a CQRS product—Relay is just the in-process nudge so that after a command mutates state, every open /subscribe loop re-reads server state and streams fresh HTML.
Flow: GET / returns the document once. GET /subscribe stays open; when something calls relay.publish, the subscribe handler rebuilds the view from board / users and pushes patches. POST /click returns 204—the browser does not get new HTML there; updates only appear on the SSE connection. Datastar morphs streamed HTML into the live DOM.
Until /subscribe has registered the tab’s user, click may not apply—the template can redirect home first. Swap in a real database when you outgrow in-memory board / users.
4. Bootstrap
Open main.py and find bootstrap(app, span). This function is where the app is wired for startup: static files, routes, anything that should exist before the server serves traffic. Stario runs it once at the beginning of the process; treat it as the composition root.
The tiles template uses this shape:
async def bootstrap(app: App, span: Span) -> None: # span is the root bootstrap tracer; attr() annotates startup for TTY/JSON/SQLite sinks. static_dir = Path(__file__).parent / "static" static_dir_display = static_dir.relative_to(Path.cwd()) span.attr("static_dir", str(static_dir_display)) app.mount("/static", StaticAssets(static_dir, name="static")) app.get("/", home, name="home") app.get("/subscribe", subscribe, name="subscribe") app.post("/click", click, name="click")span.attr records startup metadata for your telemetry sinks (where static files live, and so on).
Teardown: if you need code to run when the process shuts down (close pools, clients), write bootstrap as an async generator and use a single yield: everything before yield is startup, everything after runs on shutdown. Stario treats that shape like an async context manager—you do not wire the lifecycle by hand.
Static assets
StaticAssets points at a directory on disk. At mount time Stario scans it, fingerprints files (for example js/datastar.js is exposed under a name like js/datastar.<digest>.js), and serves cache-friendly responses. Logical paths redirect to the fingerprinted URL.
In HTML you never hard-code the hashed filename. With name="static" on the mount, each file gets a logical key such as static:js/datastar.js. Use app.url_for("static:js/datastar.js") (and the same for CSS) in Python so links and script src always match what the server expects. The page helper in the template does exactly that for the stylesheet and Datastar bundle.
5. HTML in Stario
Tags come from from stario import html as h (same module as import stario.html as h; the h alias avoids clashing with telemetry Span). Each tag is callable: you pass positional arguments in order. Each mapping (usually a dict) merges into the element’s attributes; anything that is not a mapping becomes child content. Several dicts in a row merge left-to-right, which is why Datastar helpers—small attribute fragments—slot next to your own class or id dicts.
Example:
h.Div( {"class": "row"}, h.P("Hello"),)# → roughly: <div class="row"><p>Hello</p></div>Strings are escaped in text contexts. For the full rules, see HTML.
The document shell and static URLs in tiles look like this (page wraps every view):
def page(app: App, *children): """Base HTML page with Datastar and styles served from static assets.""" return h.HtmlDocument( {"lang": "en"}, h.Head( h.Meta({"charset": "UTF-8"}), h.Meta( {"name": "viewport", "content": "width=device-width, initial-scale=1"} ), h.Title("Tiles - Stario App"), h.Link({"rel": "stylesheet", "href": app.url_for("static:css/style.css")}), h.Script({"type": "module", "src": app.url_for("static:js/datastar.js")}), ), h.Body(*children), )The main view wires signals, the live subscription, and the board:
def home_view(user_id: str, app: App): """Full home page - user_id passed via signals for SSE.""" return page( app, h.Div( {"id": "home", "class": "container"}, ds.signals({"user_id": user_id}, ifmissing=True), ds.init(ds.get(app.url_for("subscribe"), retry="always")), h.H1("Tiles - Stario App"), h.P( {"class": "subtitle"}, "Click cells to paint. Everyone sees changes live!", ), info_panel_view(user_id), board_view(app), toy_inspector("bottom-right"), ), )ds.signals(..., ifmissing=True) merges defaults into page signals only when a key is missing, so reconnects do not wipe client-held signal values.
ds.init(...) becomes a data-init attribute on the element. In Datastar, init runs when the node is first connected—here it starts behaviour on mount. ds.get(...) builds the @get action: a GET to the subscribe URL (with retry="always"), which is what keeps the SSE stream open.
The same user_id is read again in subscribe and click with await ds.read_signals(c.req) because Datastar attaches the signals blob to its requests.
Board cells use ds.on so a click issues a @post to /click with cellId in the query string:
def board_view(app: App): """The game board - a grid of cells.""" rows = [] for row in range(GRID_SIZE): cells = [] for col in range(GRID_SIZE): cell_id = row * GRID_SIZE + col color = board.get(cell_id) cells.append(cell_view(cell_id, color)) rows.append(h.Div({"class": "row"}, *cells)) is_complete = len(board) == GRID_SIZE * GRID_SIZE board_class = "board complete" if is_complete else "board" return h.Div( {"id": "board", "class": board_class}, ds.on( "click", f""" let id = evt.target.dataset.cellId; if (id) {{ @post(`{app.url_for("click")}?cellId=${{id}}`); }} """, ), *rows, )6. Handler shape: Context and Writer
Handlers always look like:
async def handler(c: Context, w: Writer) -> None: ...c is the per-request context: c.req, c.app (the App instance), c.span, c.route, and c.state for middleware.
w is the response side: status, headers, body, streaming, SSE. The helpers in stario.responses and stario.datastar.sse know how to finish a response correctly.
7. First handler: home
async def home(c: Context, w: Writer) -> None: """Serve the home page with a fresh user_id.""" user_id = str(uuid.uuid4())[:8] c.span.attr("user_id", user_id) responses.html(w, home_view(user_id, c.app))This is normal request/response: build HTML once and send it with responses.html.
8. Command handler: click
click validates signals and cellId, returns 204 immediately, then mutates board and publishes so every subscribe loop pushes a fresh home_view.
async def click(c: Context, w: Writer) -> None: """Handle cell click - update board and broadcast.""" signals = await ds.read_signals(c.req) user_id = str(signals.get("user_id", "")) if not user_id or user_id not in users: c.span.event( "No user id or user not connected", {"user_id": user_id} ) responses.redirect(w, c.app.url_for("home")) return cell_id_param = c.req.query.get("cellId") if cell_id_param is None: c.span.event( "No cell id", {"hint": "pass cellId as query parameter"} ) responses.redirect(w, c.app.url_for("home")) return cell_id = int(cell_id_param) c.span.attrs({"user_id": user_id, "cell_id": cell_id}) responses.empty(w, 204) c.span.event("command.accepted", {"cell_id": cell_id}) user_color = color_for_user(user_id) if board.get(cell_id) == user_color: board.pop(cell_id, None) else: board[cell_id] = user_color relay.publish("click", user_id) c.span.event("relay.published", {"topic": "click", "user_id": user_id})The 204 is “the request was accepted,” not “every side effect succeeded.” Heavier work can run after the 204 in the same coroutine or via app.create_task.
After you have started an SSE response on a given Writer, do not call responses.html, redirect, or empty on that same writer—the runtime rejects double completion.
9. Long-lived handler: subscribe
async def subscribe(c: Context, w: Writer) -> None: """ Long-lived SSE stream: stay subscribed to relay while the tab is open, push fresh HTML whenever shared state changes; tear down presence after unsubscribing so leave is not delivered to a closed connection. """ signals = await ds.read_signals(c.req) my_user_id = str(signals.get("user_id", "")) if not my_user_id: c.span.event( "No user id", {"hint": "subscribe SSE before posting actions"} ) responses.redirect(w, c.app.url_for("home")) return async with relay.subscribe("*") as live: users.add(my_user_id) relay.publish("join", my_user_id) c.span.event("on_join", {"user_id": my_user_id}) c.span.attr("user_id", my_user_id) async for event, from_user_id in w.alive(live): c.span.event( "on_event", {"event": event, "from_user_id": from_user_id} ) ds.sse.patch_elements(w, home_view(my_user_id, c.app)) users.discard(my_user_id) relay.publish("leave", my_user_id) c.span.event("on_leave", {"user_id": my_user_id})Subscribe before publishing so this client’s queue exists. w.alive(live) stops the loop when the tab disconnects or the server shuts down. After the async with, presence is removed and leave is published so other tabs update.
10. Try it: reset the board (small extension)
The stock template does not include this; it is a concise exercise that matches the same command → relay → subscribe pattern.
Add a named POST route next to click:
app.post("/reset", reset_board, name="reset")Implement the handler like click: validate user_id and membership, return 204 quickly, mutate shared state, then publish so SSE clients refresh from source of truth.
async def reset_board(c: Context, w: Writer) -> None: signals = await ds.read_signals(c.req) user_id = str(signals.get("user_id", "")) if not user_id or user_id not in users: responses.redirect(w, c.app.url_for("home")) return responses.empty(w, 204) board.clear() relay.publish("reset", user_id)Use any relay topic string you like; subscribe already listens on "*", so "reset" will wake the same loops as "click".
In home_view, add a small control beside the subtitle (or under the info panel) so it feels part of the same layout—ds.on with @post to the reset URL, same pattern as the board:
h.Div( {"class": "subtitle-row"}, h.P( {"class": "subtitle"}, "Click cells to paint. Everyone sees changes live!", ), h.Button( {"type": "button", "class": "reset-btn"}, ds.on("click", f"@post(`{app.url_for('reset')}`);"), "Reset board", ),)Add styles that reuse the template’s variables so the button matches the warm, light UI (drop this into static/css/style.css):
.subtitle-row { display: flex; flex-direction: column; align-items: center; gap: 0.75rem;} .reset-btn { padding: 0.4rem 0.85rem; font-size: 0.8rem; font-weight: 600; border-radius: var(--radius); border: 1px solid var(--border-strong); background: var(--surface); color: var(--fg); cursor: pointer;} .reset-btn:hover { background: var(--surface-hover); border-color: var(--accent);}Rebuild home_view to use the subtitle + button row instead of the lone h.P if you adopt the snippet above. After a reset, every connected tab should see an empty board without reloading.
Where to go next
When this file grows unwieldy, Structuring larger applications shows how to split files and mounts. For how to pass dependencies into handlers, see Injecting dependencies. For typed signal bodies and reusable patch_* helpers, see Datastar: Reading and writing signals.