Datastar
Datastar is a ~11KB hypermedia framework. The browser gets one script tag; the server sends HTML. There is no client-side view layer, no build step, and no JSON API for the UI to consume.
Two things make it work:
data-*attributes on the HTML give elements reactivity and let them make backend requests.- SSE events let one backend response patch the DOM and signals zero or more times, for as long as the connection stays open.
When to use this skill
- Live-updating UI: dashboards, feeds, progress, chat, collaborative editing
- Anything "multiplayer" — several browsers looking at the same state
- Replacing a SPA + JSON API pair where the API exists only to feed the UI
- Working in a codebase that already has
data-on:,data-signals:, ordatastar-patch-elementsin it
When NOT to use this skill
- Offline-first or heavy client-side computation — the server is unreachable and Datastar has nothing to push
- A JSON API that non-browser clients consume — that is a normal API; build it normally and let Datastar sit alongside it
- Static content with no interactivity — plain HTML is fine
The Tao of Datastar
These are the core team's opinions. They are not style preferences; the design of the library assumes them, and fighting them produces code that is harder than plain Datastar would have been.
1. State in the right place. Most state lives in the backend. The frontend is exposed to the user and cannot be trusted as a source of truth.
2. Start with the defaults. Default options are the recommendation for the majority of apps. Before changing one, work out why the default is wrong here.
3. Patch elements and signals. The backend drives the frontend by patching. The frontend does not compute its next view.
4. Use signals sparingly. Reach for a signal only for user interactions (toggling visibility) and for carrying new state to the backend (form inputs). Signals holding data that the backend also knows is duplicated state, and it will drift.
5. In morph we trust. Send big chunks of DOM — up to the <html> tag if you
like ("fat morph"). Morphing touches only what actually differs and preserves
focus, scroll, event listeners, and CSS transitions. Do not hand-roll
fine-grained updates. Use data-ignore-morph on the rare element that must be
left alone.
6. SSE responses. One response can carry 0..N events. There is no benefit
to a content type other than text/event-stream.
7. Compress the stream. Fat morph plus repetitive HTML compresses extraordinarily well — Brotli ratios around 200:1 on SSE streams are normal.
8. Backend templating. The backend generates the HTML, so use its templating language to keep things DRY. The same template renders the first page load and every subsequent fragment.
9. Page navigation and history are solved. Use <a href> and let the
browser manage history. Each page is a resource.
10. CQRS. Segregate commands (writes) from requests (reads): a single long-lived request receives updates, and many short-lived requests send commands. This is the pattern that makes real-time collaboration fall out for free.
11. No optimistic updates. Do not show success before the backend confirms it. Use a loading indicator instead and let the truth arrive on the stream.
12. Accessibility is yours. Datastar stays out of the way. Semantic HTML,
ARIA via data-attr:, keyboard and screen reader support are your job.
The CQRS loop
This is the shape almost every Datastar app takes:
browser server
| GET / -------------> render whole page
| <------------------------------- HTML (includes the live region)
|
| GET /updates (data-init) ---------> register watcher [openWhenHidden: true]
| <------------------------------- event: datastar-patch-elements (initial)
| ... blocks on state change ...
| POST /add -------------> mutate state, wake all watchers
| <------------------------------- 204 No Content, empty body
| <------------------------------- event: datastar-patch-elements (on the open stream)
Two rules follow, and they are the ones people break:
- The read stream never returns. It renders, blocks, renders again.
- Write handlers render nothing. They mutate and answer
204. The result reaches the screen on the stream, not in the write's response.
Every other browser watching the same state gets the same patch. Nothing in the write handler knows how many clients exist — that is why it is multiplayer for free.
The wire protocol
Two event types. That is the entire protocol.
datastar-patch-elements
event: datastar-patch-elements
data: elements <div id="foo">Hello world!</div>
Every event ends with a blank line. Multi-line HTML needs one
data: elements line per line of HTML:
event: datastar-patch-elements
data: elements <main id="board">
data: elements <h1>Messages</h1>
data: elements </main>
Default mode is outer: the element is morphed into the DOM element with the
matching id. Put ids on top-level morph targets, and on anything inside
them whose state must survive (listeners, transitions).
Optional data: lines, all before data: elements:
| Line | Effect |
|---|---|
data: selector #foo |
CSS selector for the target. Not needed for outer/replace |
data: mode outer |
Morph outer HTML (default, recommended) |
data: mode inner |
Morph inner HTML |
data: mode replace |
Replace outer HTML without morphing |
data: mode prepend / append |
Insert as first/last child of the target |
data: mode before / after |
Insert as a sibling |
data: mode remove |
Remove the target. Needs selector, sends no elements |
data: namespace svg / mathml |
Patch into a non-HTML namespace |
data: useViewTransition true |
Wrap the patch in a view transition |
data: viewTransitionSelector #main |
Scope the view transition |
Removing an element:
event: datastar-patch-elements
data: selector #foo
data: mode remove
datastar-patch-signals
event: datastar-patch-signals
data: signals {foo: 1, bar: 2}
Set a signal to null to remove it. data: onlyIfMissing true patches only
signals that do not yet exist.
Non-SSE responses
An action's response may also be text/html (patched with optional
datastar-selector / datastar-mode / datastar-use-view-transition
headers), application/json (signals to patch), text/javascript (script to
execute), or an empty 204. text/event-stream is the one to reach for; the
others exist for endpoints that only ever do one thing.
Attributes
Only ~15 matter in practice.
| Attribute | What it does |
|---|---|
data-init="expr" |
Run an expression when the attribute enters the DOM. Where the read stream starts |
data-on:click="expr" |
Event listener. evt and el are in scope |
data-bind:name |
Two-way bind an input to a signal |
data-signals:name="v" |
Declare a signal with an initial value |
data-text="$expr" |
Set text content reactively |
data-show="$expr" |
Toggle visibility |
data-class:active="$e" |
Toggle a class |
data-attr:disabled="$e" |
Set any HTML attribute reactively — this is how you do ARIA |
data-computed:n="expr" |
Read-only derived signal |
data-effect="expr" |
Run an expression whenever its signal dependencies change |
data-indicator:fetching |
Signal that is true while a request from this element is in flight |
data-ref:name |
Signal referencing the element itself |
data-ignore |
Tell Datastar to skip this element's data-* attributes |
data-ignore-morph |
Never morph this element |
data-preserve-attr="x" |
Keep client-side state of listed attributes through a morph (e.g. open) |
data-json-signals |
Dump all signals as text. Debugging only |
Casing
data-* attributes are case-insensitive per the HTML spec, so Datastar has
rules:
- Keys of signal-defining attributes (
data-bind:,data-signals:,data-computed:,data-indicator:) become camelCase.data-signals:my-signaldefines$mySignal. - Keys of everything else become kebab-case.
data-class:text-blue-700togglestext-blue-700;data-on:rocket-launchedlistens forrocket-launched. __case.camel/.kebab/.snake/.pascaloverrides that. Listening for awidgetLoadedevent:data-on:widget-loaded__case.camel.
Signal names can go in the key or the value — data-bind:foo and
data-bind="foo" are the same. Use whichever your template language makes
cleaner.
Common data-on modifiers
__prevent __stop __once __passive __capture __window __document
__outside __viewtransition, plus __debounce.500ms[.leading|.notrailing],
__throttle.500ms[.noleading|.trailing], __delay.500ms.
<input data-bind:query data-on:input__debounce.300ms="@get('/search')">
data-on:submit already calls preventDefault() on forms. __prevent there
is harmless but redundant.
Evaluation order
Attributes apply in DOM order, depth-first. This matters exactly once, and it bites everyone: an indicator signal must be created before the fetch that uses it.
<!-- correct: indicator first -->
<div data-indicator:fetching data-init="@get('/endpoint')"></div>
Expressions
Datastar expressions are JavaScript with $name interpolated to signal values.
el is the current element; evt is the event in data-on:. Actions are
prefixed @ — only @-prefixed helpers can run, which is what keeps arbitrary
JS out of attributes.
Signals whose names begin with _ are local: they are never sent to the
backend. Use them for pure UI state like $_menuOpen.
Actions
@get(uri, opts) @post @put @patch @delete all behave identically apart
from the method.
Every request carries:
- a
Datastar-Request: trueheader - all signals except
_-prefixed ones — as a?datastar=query param onGET/DELETE, as a JSON body otherwise
Sending all signals every time is deliberate: the backend gets the complete
frontend state. Filtering is possible via filterSignals but is not
recommended.
Options worth knowing:
| Option | Default | Notes |
|---|---|---|
openWhenHidden |
false for GET, true others |
GET streams close when the tab is hidden. Set true — see below |
requestCancellation |
'auto' |
Cancels in-flight requests to the same URL+method |
contentType |
'json' |
'form' submits the closest form instead of signals |
headers |
{} |
CSRF tokens go here |
retry |
'auto' |
Network errors only. 'error', 'always', 'never' also valid |
retryInterval |
1000 |
Scaled by retryScaler (2), capped at retryMaxWait (30s) |
retryMaxCount |
10 |
Keep the read stream open when the tab is hidden
Pass {openWhenHidden: true} on the long-lived read by default.
<body data-init="@get('/updates', {openWhenHidden: true})">
The default for @get is false: Datastar closes the stream when the tab is
backgrounded and reopens it when the tab becomes visible again. For a live view
that is the wrong behavior. A backgrounded tab silently stops receiving
patches, so it shows stale state, and every tab switch costs a reconnect — a
new render of the full live region, and a fresh watcher registration on the
server. In a multiplayer app the user comes back to a view that was wrong for
however long they were away.
This is the one default worth overriding as a matter of course. The cost is a held connection and some battery on mobile; the benefit is that the view is always current, which is the entire point of the stream. Leave the default in place only when the page genuinely does not matter while hidden and you are optimizing for battery.
The false default only applies to @get. Writes (@post, @put, @patch,
@delete) already default to true, so a command in flight is not cancelled
by a tab switch.
Signal helpers: @peek(fn) reads signals without creating a dependency;
@setAll(value, filter) and @toggleAll(filter) operate on matching signals.
Fetch lifecycle events fire as datastar-fetch with
evt.detail.type in started / finished / error / retrying /
retries-failed.
Backends
Python — datastar-py
uv add datastar-py # or add it to a PEP 723 script block
Framework helpers live in datastar_py.<framework>: starlette, fastapi,
django, quart, sanic, litestar, fasthtml. The FastAPI module
re-exports the Starlette one and adds a ReadSignals dependency.
from datastar_py import ServerSentEventGenerator as SSE
from datastar_py.starlette import DatastarResponse, datastar_response, read_signals
The long-lived read — an async generator, one yield per event:
@datastar_response
async def updates(request):
store = request.app.state.store
with store.watch() as changed:
while True:
yield SSE.patch_elements(render_board(store))
await changed.wait()
changed.clear()
The write — mutate, render nothing:
async def add(request):
signals = await read_signals(request) or {}
message = str(signals.get("message", "")).strip()
if message:
request.app.state.store.add(message)
return DatastarResponse() # empty content -> 204
Event constructors:
SSE.patch_elements(html, selector=None, mode=None, use_view_transition=None)
SSE.remove_elements("#foo")
SSE.patch_signals({"count": 3},
SSE.execute_script("console.log('hi')")
SSE.redirect("/somewhere")
DatastarResponse accepts one event, a list, or an async iterable, and sets
Content-Type: text/event-stream, Cache-Control: no-cache, and
X-Accel-Buffering: no. Anything implementing __html__ (htpy, fasttags) can
be passed straight to patch_elements.
datastar_py.attributes.attribute_generator builds attributes with type
checking, if you would rather not hand-write them:
from datastar_py import attribute_generator as data
data.on("click", "@post('/add')").debounce(500) # data-on:click__debounce.500ms__case.kebab="..."
FastAPI variant of signal reading:
from datastar_py.fastapi import ReadSignals
@app.post("/add")
async def add(signals: ReadSignals) -> DatastarResponse: ...
Go — datastar-go
go get github.com/starfederation/datastar-go
NewSSE writes the SSE headers (including X-Accel-Buffering: no) and flushes
every event as it is sent. The stream stays open until the context is cancelled
or the handler returns — so the handler is the event loop.
import "github.com/starfederation/datastar-go/datastar"
func handleUpdates(s *store) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sse := datastar.NewSSE(w, r) // add datastar.WithCompression() for Brotli/gzip
changed, stop := s.watch()
defer stop()
for {
if err := sse.PatchElements(renderBoard(s)); err != nil {
return // client went away
}
select {
case <-sse.Context().Done():
return
case <-changed:
}
}
})
}
The write side. ReadSignals takes a JSON body on writes and the ?datastar=
query parameter on GET/DELETE:
func handleAdd(s *store) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var signals struct {
Message string `json:"message"`
}
if err := datastar.ReadSignals(r, &signals); err != nil {
http.Error(w, "bad signals", http.StatusBadRequest)
return
}
s.add(signals.Message)
w.WriteHeader(http.StatusNoContent)
})
}
Useful methods on the generator:
sse.PatchElements(html, datastar.WithModeInner(), datastar.WithNamespaceSVG())
sse.PatchElementTempl(component) // templ integration
sse.RemoveElementByID("row-7")
sse.PatchSignals([]byte(`{"count":3}`))
sse.MarshalAndPatchSignals(struct{ Count int `json:"count"` }{3})
sse.ExecuteScript("console.log('hi')")
sse.Redirect("/somewhere")
sse.IsClosed() // cheap check before expensive work
datastar.WithCompression() on NewSSE is worth taking: it negotiates
Brotli/gzip/zstd from the client's Accept-Encoding, and fat-morph HTML
compresses at ratios around 200:1. curl sends no Accept-Encoding by default,
so the stream stays readable by hand while browsers get compression.
No SDK at all
The protocol is small enough to write by hand when a dependency is not worth it:
func patchElements(w io.Writer, html string) {
fmt.Fprint(w, "event: datastar-patch-elements\n")
for _, line := range strings.Split(strings.TrimRight(html, "\n"), "\n") {
fmt.Fprintf(w, "data: elements %s\n", line)
}
fmt.Fprint(w, "\n")
}
Plus signal reading: JSON-decode r.URL.Query().Get("datastar") on GET, the
body otherwise, and remember to flush after every event. Prefer the SDK — it is
one direct dependency and it gets you compression, signal patching, script
execution, redirects, and correct flushing for free.
Patterns
Broadcast to every watcher
The read handler needs to block on "something changed". Coalescing is correct — a watcher re-reads current state when it wakes, so a dropped duplicate wake is never a lost update.
class Store:
def __init__(self):
self._watchers: set[asyncio.Event] = set()
@contextlib.contextmanager
def watch(self):
changed = asyncio.Event()
self._watchers.add(changed)
try:
yield changed
finally:
self._watchers.discard(changed)
def _notify(self):
for changed in self._watchers:
changed.set()
In Go, a map[chan struct{}]struct{} with a non-blocking select send does the
same job. For state that must survive a restart or span processes, put it in
SQLite, Postgres LISTEN/NOTIFY, or NATS and watch that instead.
One template, two callers
The live region template renders both the first page load and every subsequent patch. This is Tao #8, and it is what keeps markup in one place.
<body data-init="@get('/updates', {openWhenHidden: true})">
{% include "board.html" %} <!-- first paint -->
</body>
yield SSE.patch_elements(render("board.html")) # every update
Loading indicators
The signal form works for isolated requests:
<button data-indicator:fetching data-on:click="@post('/do')"
data-attr:disabled="$fetching">Go</button>
<span data-show="$fetching">Loading…</span>
Under CQRS the write returns 204 immediately, so $fetching flips back
before the stream has repainted. Set the indicator manually and let the
incoming patch clear it:
<button data-on:click="el.classList.add('loading'); @post('/do')">Go</button>
Forms
<form data-on:submit="@post('/add'); $message = ''">
<input data-bind:message name="message" autocomplete="off">
<button type="submit">Send</button>
</form>
Clearing the signal after the @post is fine — the action has already captured
the signals. For file uploads, either bind the input (data-bind:files
base64-encodes into a signal) or use a real form with
enctype="multipart/form-data" and {contentType: 'form'}.
Per-session state
One global store means every browser sees the same thing, which is the point for a shared board and wrong for a per-user view. Key the store by session, read the session id in both the stream and the write handlers, and notify only that session's watchers. Everything else in the shape is unchanged.
Gotchas
datastar-py's read_signals() returns None without the
Datastar-Request header. It checks for the header before parsing anything.
datastar.js always sends it; your curl does not. (The Go SDK does not
check — datastar.ReadSignals parses whatever arrives, so a hand-rolled curl
against a Go backend works without the header and the same curl against a
Python backend silently does nothing.) Testing a write by hand:
curl -X POST -H 'Datastar-Request: true' -H 'Content-Type: application/json' \
-d '{"message":"hi"}' http://localhost:8000/add
Without those two headers a Python handler silently sees no signals — no error, no log line, just nothing happening.
In Go, call ReadSignals before NewSSE. Upgrading the response first
closes the request body out from under it. The SDK's error message says so
outright ("are you sure you created the SSE AFTER the ReadSignals?"), but
only once you are already reading errors.
Never wait for networkidle. The SSE stream is a request that never
completes, so the network is never idle. Playwright:
page.goto(url, wait_until="domcontentloaded")
page.wait_for_selector("li:has-text('expected')")
No write timeout on the server. Anything that reaps a long-open connection
severs the stream mid-flight: Go's http.Server.WriteTimeout, nginx
proxy_read_timeout, ingress idle timeouts, load balancer idle timeouts.
Proxy buffering swallows the stream. The SDKs set X-Accel-Buffering: no
for nginx. Behind other proxies, disable response buffering explicitly, or
patches arrive in a lump when the connection closes.
GET streams close when the tab is hidden. That is the default
(openWhenHidden: false). Override it on the read stream by default:
@get('/updates', {openWhenHidden: true}). Otherwise a backgrounded tab stops
receiving patches and comes back stale.
Request cancellation is per URL+method. Two elements both calling
@post('/add') will cancel each other by default. Different endpoints, or
{requestCancellation: 'disabled'}.
Morph targets need ids. outer mode matches top-level elements by id. A
fragment without one has nothing to morph into and will not appear.
Signals prefixed with _ never reach the backend. Intentional, and a
five-minute debugging session if you forget.
Do not render from a write handler. If a POST handler is producing HTML,
the pattern has been left behind. Mutate, return 204, let the stream paint.
Do not duplicate backend state in signals. Signals are for user interactions and for carrying input up. Anything the backend also knows will drift.
Testing
The stream is testable with curl alone:
# first paint
curl -s http://localhost:8000/ | grep 'id="board"'
# write, then confirm it lands on the read stream
curl -s -X POST -H 'Datastar-Request: true' -H 'Content-Type: application/json' \
-d '{"message":"from curl"}' http://localhost:8000/add
curl -sN -m 3 http://localhost:8000/updates # exits 28 on timeout; that is correct
curl -sN -m N on a stream always ends in exit code 28. The stream is supposed
to never end.
For the attributes themselves, two browser contexts against one server proves the multiplayer property end to end:
a, b = ctx.new_page(), ctx.new_page()
a.goto(url, wait_until="domcontentloaded")
b.goto(url, wait_until="domcontentloaded")
a.fill("input[name=message]", "hello")
a.click("button[type=submit]")
b.wait_for_selector("li:has-text('hello')") # b never talked to a
Deployment
- Compress the stream. Brotli on
text/event-streamgets ratios around 200:1 on repetitive fat-morph HTML. This is the single biggest win. - No idle or read timeouts anywhere on the path — server, ingress, LB.
- Disable proxy buffering (
X-Accel-Buffering: no, or the equivalent). - Budget one open connection per browser tab. A long-lived request per
viewer is the cost of the model; size worker/connection pools accordingly and
prefer async runtimes over thread-per-request. With
openWhenHidden: true(the recommended default) background tabs hold their connections too, so budget for every open tab, not every active one. - Sticky sessions or shared state. With more than one replica, a client's
stream lands on one instance while its writes may land on another. Either
pin sessions or broadcast changes through shared infrastructure (Postgres
LISTEN/NOTIFY, NATS, Redis) so every replica's watchers wake. - Pin the CDN URL to a release tag, and keep it in step with the SDK version:
<script type="module"
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.2/bundles/datastar.js"></script>
Starting points
Two cookiecutter templates in this repo implement exactly the shape above, in one file each:
project-templates/go-datastar-minimal— Go,datastar-go+html/template,go run .project-templates/python-datastar-minimal— Python, PEP 723 deps,datastar-py+ Starlette + Jinja2,./main.py
project-templates/go-real-time-service is the same architecture with templ,
an embedded NATS broker for per-session state, metrics, and k8s manifests.
References
- Docs, single file for LLMs: https://data-star.dev/docs.md
- The Tao of Datastar: https://data-star.dev/guide/the_tao_of_datastar
- Attributes: https://data-star.dev/reference/attributes
- Actions: https://data-star.dev/reference/actions
- SSE events: https://data-star.dev/reference/sse_events
- Python SDK: https://github.com/starfederation/datastar-python
- Go SDK: https://github.com/starfederation/datastar-go