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 /subscribe 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.
Two kinds of request#
Once a page is live, requests do two different jobs, and it helps to keep them apart.
The subscribe 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 /subscribe.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 subscribe 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 subscribe(c: Context, w: Writer) -> None: async with relay.subscribe("board") as live: sse = SSE(w) sse.patch_elements(board_view(load_board())) async for _ in c.alive(live): 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(live) stops the loop when the tab closes or the server shuts down. If the connection drops, Datastar opens /subscribe again and the handler sends fresh state; nothing needs to be replayed.
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 subscribe 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 subscribe 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. In Chat room, Lobby.create and Lobby.delete end this way.
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 subscribe loop sees the room is gone and calls sse.navigate(LOBBY.href()).
A plain <form method="post"> with a 303 also works, and needs no Datastar at all. Use it when the page has no Datastar on it anyway. Do not mix the two: a 303 answered to a Datastar request is the quiet failure above.
Render from state#
The subscribe 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#
Datastar also has signals: small pieces of state that live in the page. A draft in a text box, an open menu, a tab the user picked. Those are fine in the browser and never need the server. A command can also carry signals along as extra context.
Signals are not the system of record. The server holds the truth, and the subscribe loop renders it. Prefer few signals. URLs, hidden fields, and server-rendered error text cover more than you expect.
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)SUBSCRIBE = Route("GET /subscribe")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.