A live page is a page that changes while you look at it. Someone else paints a cell, a job finishes, a message arrives, and the page shows it without a reload. This essay explains the shape Stario recommends for that, and why each part is where it is.
The shape has three routes:
GET / the first pageGET /live stays open and pushes HTMLPOST /… a command: validate, reply, then do the workMost pages do not need the second route. A plain request and response is easier to reason about, and you should start there. Add the open response when the page needs to change on its own.
Three kinds of action#
Before any of that, sort what the user is doing.
Going somewhere is a plain <a href>: links move the user between screens and need no Datastar at all.
Asking for something only this tab needs — search as you type, a filter, a preview — is a GET route that reads the signals and answers SSE(w).patch_elements(part), where part is the element that asked. It changes no server state, so it needs no stream and no Relay. Use POST instead of GET only when the input is large, like pasted text. Do not keep the result on the server.
Changing shared state — add, edit, delete, start a job — is a command, and a command is what makes a page live. A page is live only when it shows shared state that can change while it is open; a private read alone stays a plain request and response.
Two kinds of request#
Once a page is live, requests do two different jobs, and it helps to keep them apart.
The live request owns the view. It is one HTTP response that never finishes. Whenever the state behind the page changes, the handler renders the page again and writes the new HTML into that response. Datastar, in the browser, morphs the new HTML into the live document. One connection carries every update for as long as the tab is open.
A command carries intent. The user clicked, typed, or submitted something. The command request is short: it checks that the request makes sense, replies, and then changes state. It does not render the page.
Some people call this split CQRS. The name does not matter. What matters is that one route is responsible for what the user sees and the other routes are responsible for what the user did.
The loop#
Here is one round, from a click to a changed page:
The page is open and holds
GET /live.The user clicks. The browser sends
POST /click.The command validates the request and replies with an empty 204.
The command changes state and publishes "something changed".
Every open live handler wakes, renders the page from state, and writes the HTML.
Datastar morphs each tab.
In one process, step 4 is a Relay: in-process publish and subscribe. With several processes it is a broker. The handlers do not change; only the thing they publish to does.
async def live(c: Context, w: Writer) -> None: async with relay.subscribe("board", latest=True) as events: sse = SSE(w) sse.patch_elements(board_view(load_board())) async for _ in c.alive(events): sse.patch_elements(board_view(load_board())) async def click(c: Context, w: Writer) -> None: cell = parse_cell(c) w.empty() paint(cell) relay.publish("board", "painted")Subscribe before you publish anything this tab must see. c.alive(events) stops the loop when the tab closes or the server shuts down. If the connection drops, Datastar opens /live again and the handler sends fresh state; nothing needs to be replayed.
latest=True is worth the extra word here. The inbox then keeps only the newest message per subject, so a tab that falls behind repaints once rather than replaying a backlog of wake-ups — a repaint needs no backlog. The Relay reference has maxsize= for the other bound.
Why the command does not render#
It would be shorter for click to return the new board. Two things go wrong when it does.
First, there are now two sources of truth on the tab that clicked: the response to the command, and the stream. They race. Whichever arrives second wins, and it is not always the newer one.
Second, the other tabs still need the stream. So you would write the rendering twice, once for the command and once for the live loop, and keep them in agreement.
With the command reduced to "reply, then publish", there is one render path, and every tab, including the one that clicked, goes through it. A failed command is handled the same way. It publishes the failure, and the live loop renders a banner. Failure is a view of state like any other.
Leaving the page#
Some commands end somewhere else: delete this room and go to the lobby, log out and go to the login page. Those are not live-page commands, and a status code cannot do the job through Datastar.
Datastar sends commands with fetch, and fetch follows a redirect on its own. The server replies 303 Location: /lobby, the browser fetches /lobby, and Datastar receives a 200 with the lobby's HTML. It morphs that HTML into the current page by id. The address bar still shows the room, and the history gets no entry. It looks like a redirect and is not one.
So the command does its work and then tells the tab where to go: SSE(w).navigate(LOBBY.href()). That sends a one-line script, which is also how the official Datastar client SDKs redirect. The URL changes and the history gets its entry. It is one extra line in the handler, and it keeps the command in the same shape as every other command: read the signals, do the work, answer.
The same call reaches the tabs that did not click. When one tab deletes the room, the tabs still sitting on it made no request, so no response can reach them. Their stream can. The live loop sees the room is gone and calls sse.navigate(LOBBY.href()).
Once a page has inputs, those inputs go through Datastar actions. A 303 answered to a Datastar request is the quiet failure above.
Render from state#
The live handler does two separate things: it collects state, and it turns state into HTML. Keep them as two functions. The view, board_view(board), is a pure function. The loader, load_board(), talks to SQLite or memory or whatever holds the truth. The Relay message carries only "something changed"; it never carries HTML.
Send a large piece of HTML each time: the main content or the whole document. This is called fat morphing, and it is the right default. The code stays one function of state. Datastar compares the new tree to the live one and touches only what differs. The writer compresses the stream, and because each patch resembles the last, repeats cost very little on the wire. Narrow the patch with selector= when you have measured a reason to, not before.
Signals#
Morphing HTML is the default way to show state: the server renders the truth and the page shows it. Signals carry what the HTML cannot — form input, and client-only state the server never needs, such as a request indicator or a toggle the user flipped. A command can also carry signals along as extra context.
Stario passes signal names to Datastar unchanged, and a dot is a nested path. A name with a leading underscore, like _menu_open, is a Datastar local signal: the browser keeps it and does not send it to the server.
UI state the user controls survives a morph only when the HTML holds it. A dialog or <details> keeps its open state across a repaint with data.preserve_attr("open"), and the server never renders a user-opened dialog as open. A subtree the client owns, such as an editor with its own state, uses data.ignore_morph().
Keep the signal count small. To edit one row at a time, one signal per field plus one editing_id is enough — not one signal per row or per cell. Correctness first; this is a size rule, not a reason to change behaviour.
data.indicator("_saving") is a signal bound to the request itself: it is true while that request is in flight. Use it like any other signal — data.attr("disabled", "$_saving"), data.class_("loading", "$_saving"), or data.show("$_saving").
Routes#
A Route is one method on one path, so GET /board and POST /board are two routes that share a string. Put all of a feature's routes in one module, usually urls.py, that imports nothing from the feature. Views import it because links need href(). Handlers import the views. Registration imports the routes and calls app.add. The import graph points one way, and no code asks the running app "what is this URL called".
# urls.pyfrom stario import Route BOARD = "/board"HOME = Route("GET", BOARD)CLICK = Route("POST", BOARD)LIVE = Route("GET", BOARD + "/live")By the way#
Server-sent events are plain HTTP. You do not need a WebSocket to push HTML, and you keep caching, proxies, and compression as they are.
The redirect behaviour above was measured, not assumed: with Datastar 1.0.2 and 1.0.4, a
@postanswered by a 303 left the URL unchanged andhistory.lengththe same, while a plain form andnavigateboth changed the URL and added a history entry.This is not event sourcing and it does not need a separate read model. Those are additions you can make later; the loop does not require them.
Chat room is this shape at project size, with a feature folder, SQLite, and tests. Structuring apps shows where the files go.
Next: Realtime tiles builds this loop in one file.