🐈 Datastar in 10 Steps
A step-by-step tutorial for data-star.dev — backend-driven, hypermedia-first web apps — using a super-simple vanilla PHP backend. No SDK, no build step, no npm.
Contents
- What is Datastar?
- Installation
- Events with
data-on - Calling the backend with
@get() - The PHP backend & Patching Elements
- Streaming multiple events with SSE
- Reactive signals
- Sending signals to PHP
- Local signals &
data-indicator - Full example: live search
1.What is Datastar?
Datastar is a ~11 KB hypermedia framework that gives you:
- Backend reactivity like htmx — the backend sends HTML, the browser patches it into the DOM.
- Frontend reactivity like Alpine.js — reactive variables (signals) wired up with plain
data-*attributes.
There is no JavaScript to write, no npm, no build step. You write HTML attributes on the frontend and HTML fragments (SSE events) on the backend. That's the whole deal.
2.Installation
One script tag. That's it:
<script type="module"
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"></script>
For production, download the file and host it yourself: <script type="module" src="/js/datastar.js"></script>
my-app/
├── index.php ← the frontend (HTML with data-* attributes)
├── api.php ← one tiny vanilla backend (or several endpoints)
└── js/datastar.js ← Datastar, downloaded from the CDN
Run it with php -S localhost:8000.
3.Events with data-on
data-on:click attaches an event listener. The attribute value is a Datastar expression — JavaScript-ish, evaluated reactively:
<button data-on:click="alert('I’m sorry, Dave.')">
Open the pod bay doors, HAL.
</button>
Every DOM event works: data-on:input, data-on:submit, data-on:keydown, … plus special ones like data-on-interval and data-on-intersect.
__: data-on:click__prevent (preventDefault), data-on:click__window, data-on:input__debounce.300ms, data-on:click__once, data-on:click__outside.4.Calling the backend with @get()
Actions start with @. The backend actions — @get(), @post(), @put(), @patch(), @delete() — send a fetch request to your server:
<button data-on:click="@get('/api.php?action=hal')">
Open the pod bay doors, HAL.
</button>
<div id="hal">Waiting for an order...</div>
Datastar-Request: true header — your backend can use it to tell Datastar requests apart from normal page loads.5.The PHP backend & Patching Elements
If the response has content type text/html, Datastar morphs the top-level elements into the DOM matched by their id. The DOM must contain an element with a matching ID:
The whole PHP backend for this is five lines:
api.php<?php
header('Content-Type: text/html');
if (($_GET['action'] ?? '') === 'hal') {
echo '<div id="hal">I’m sorry, Dave. I’m afraid I can’t do that.</div>';
}
No SDK needed. Datastar sees text/html, finds <div id="hal"> in your page, and morphs the new content into it — preserving focus, event listeners and everything else that didn't change.
id on every top-level element you send back. That's how Datastar knows where the fragment belongs.6.Streaming multiple events with SSE
text/html is the quick path. The full power comes from Server-Sent Events (text/event-stream): the backend can send zero or more events in one response — including delayed, long-lived ones.
The event type for DOM patches is datastar-patch-elements. Each line of the HTML gets its own data: elements prefix, and every event ends with two newlines:
event: datastar-patch-elements
data: elements <div id="hal">
data: elements I’m sorry, Dave. I’m afraid I can’t do that.
data: elements </div>
Here's the same HAL demo in vanilla PHP, sending two patches with a pause in between:
api.php<?php
function patchElements(string $html): void {
echo "event: datastar-patch-elements\n";
foreach (explode("\n", $html) as $line) {
echo "data: elements $line\n";
}
echo "\n"; // event ends with a blank line!
}
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
while (ob_get_level() > 0) ob_end_flush(); // disable output buffering
patchElements('<div id="hal">I’m sorry, Dave. I’m afraid I can’t do that.</div>');
flush();
sleep(2);
patchElements('<div id="hal">Waiting for an order...</div>');
flush();
A small helper makes this nicer to use:
datastar.php — reusable helpers<?php
function sse_begin(): void {
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
while (ob_get_level() > 0) ob_end_flush();
}
// Patch HTML elements into the DOM (morph by id).
function patchElements(string $html, array $opts = []): void {
echo "event: datastar-patch-elements\n";
foreach (explode("\n", $html) as $line) {
echo "data: elements $line\n";
}
foreach ($opts as $k => $v) echo "data: $k $v\n"; // e.g. selector, mode
echo "\n";
flush();
}
// Merge values into the frontend signals.
function patchSignals(array $signals): void {
echo "event: datastar-patch-signals\n";
echo 'data: signals ' . json_encode($signals) . "\n";
echo "\n";
flush();
}
// Read the signals the frontend sent with the request.
function readSignals(): array {
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
return json_decode($_GET['datastar'] ?? '{}', true) ?: [];
}
return json_decode(file_get_contents('php://input'), true) ?: [];
}
// Is this a Datastar request?
function isDatastar(): bool {
return ($_SERVER['HTTP_DATASTAR_REQUEST'] ?? '') === 'true';
}
Other patch modes exist via an extra data line, e.g. data: mode inner (replace children), data: selector #list, data: mode remove. See the SSE events reference — modes: outer (default/morph), inner, replace, prepend, append, before, after, remove.
7.Reactive signals
Signals are reactive variables. In expressions they're prefixed with $. Key attributes:
| Attribute | What it does |
|---|---|
data-signals="{count: 0}" | Declare signals with initial values |
data-bind="foo" | Two-way bind an input to signal $foo |
data-text="$foo" | Bind element text to an expression |
data-show="$foo" | Show/hide based on truthiness |
data-computed:dbl="$foo * 2" | Derived, read-only signal $dbl |
data-class:active="$open" | Toggle a class |
data-attr:aria-label="$foo" | Bind any attribute |
Hyphenated names become camelCase: data-bind="foo-bar" creates $fooBar.
<div data-signals="{name: ''}">
<input data-bind="name" placeholder="Type your name…">
<div>Hello, <span data-text="$name || 'stranger'"></span>!</div>
<div data-show="$name">👋 Reactive, without a single line of JS.</div>
</div>
8.Sending signals to PHP
By default, every backend request sends all signals (except local ones — step 9). GET requests put them in the datastar query parameter; all other methods send them as a JSON body. That's the whole transport — no SDK required.
<div data-signals="{count: 0}">
<button data-on:click="@post('/api.php?action=inc')">Increment</button>
<div data-text="$count"></div>
</div>
On the backend, read the signals, do the work, and patch back — elements, signals, or both:
api.php<?php
require 'datastar.php';
if (($_GET['action'] ?? '') === 'inc') {
$signals = readSignals(); // {count: 3, ...}
$count = ($signals['count'] ?? 0) + 1;
patchSignals(['count' => $count]); // updates $count in the browser
}
Note how the state ($count) lives in the browser's signals, but the transition is confirmed by the server — and the server can patch signals at any time. That's the hypermedia flow:
- User acts → Datastar sends signals to your endpoint.
- PHP reads signals (
readSignals()), does the work. - PHP streams back
datastar-patch-elementsand/ordatastar-patch-signalsevents. - Datastar morphs the DOM and merges the signals.
9.Local signals & data-indicator
Local signals start with an underscore (_isMenuOpen) and are never sent to the backend. Use them for pure UI state:
<div data-signals="{_open: false}">
<button data-on:click="_open = !_open">Toggle</button>
<div data-show="_open">Only visible in the browser.</div>
</div>
No optimistic updates. The Tao says: wait for the server. Use data-indicator to show a loading state while a request is in flight:
<button data-on:click="@post('/api.php?action=save')" data-indicator:saving>
Save
</button>
<span data-show="$saving">Saving…</span>
More useful patterns in one snippet:
<!-- Debounced search-as-you-type -->
<input data-on:input__debounce.300ms="@get('/api.php?action=search')">
<!-- Polling -->
<div data-on-interval="5000; @get('/api.php?action=status')">…</div>
<!-- Lazy loading when scrolled into view -->
<div data-on-intersect="@get('/api.php?action=load-more')">Loading…</div>
<!-- Form without page reload (button, not submit — or use __prevent) -->
<form>
<input data-bind="email">
<button data-on:click__prevent="@post('/api.php?action=login')">Login</button>
</form>
10.Full example: live search
Everything together — bound input, debounce, indicator, PHP reading signals and morphing a result list.
index.php<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Datastar Live Search</title>
<script type="module" src="/js/datastar.js"></script>
<style>
body { font-family: system-ui; max-width: 32rem; margin: 3rem auto; }
.hit { padding: .5rem .75rem; border: 1px solid #ddd; border-radius: 6px; margin-top: .5rem; }
</style>
</head>
<body>
<h1>Search</h1>
<div data-signals="{query: '', _busy: false}">
<input
data-bind="query"
data-on:input__debounce.300ms="@get('/api.php?action=search')"
data-indicator:_busy
placeholder="Search PHP functions…"
style="width: 100%; padding: .5rem">
<div data-show="_busy">Searching…</div>
<div id="results"></div>
</div>
</body>
</html>
api.php
<?php
require 'datastar.php';
$functions = [
'array_map', 'array_filter', 'array_reduce', 'array_merge',
'str_contains', 'str_repeat', 'str_replace', 'strlen',
'json_encode', 'json_decode', 'preg_match', 'usort',
];
$action = $_GET['action'] ?? '';
if ($action === 'search') {
$signals = readSignals();
$query = mb_strtolower(trim($signals['query'] ?? ''));
// Fake latency to show off the indicator.
usleep(300_000);
$hits = $query === ''
? []
: array_values(array_filter(
$functions,
fn ($f) => str_contains($f, $query)
));
if ($hits === []) {
patchElements('<div id="results"><p style="color:#888">No matches.</p></div>');
exit;
}
// Build the fragment. Top-level id = morph target.
$html = '<div id="results">';
foreach ($hits as $hit) {
$html .= '<div class="hit">' . htmlspecialchars($hit) . '()</div>';
}
$html .= '</div>';
patchElements($html);
}
What you learned
- One script tag installs Datastar — no build step, no npm.
data-on:*wires events, actions (@get,@post, …) call your backend.- The backend returns HTML fragments (
text/htmlor SSEdatastar-patch-elements) that are morphed into the DOM by elementid. - Signals (
$foo) are reactive state;data-bind,data-text,data-showbind them to the DOM. - Every request carries all signals (JSON body or
datastarquery param) — read them in PHP withjson_decode, nothing more. - The backend can patch signals back with
datastar-patch-signals. - Underscore signals stay local;
data-indicatorreplaces optimistic updates.
Where to go next
- Guide: Getting Started · Reactive Signals · Backend Requests · The Tao of Datastar
- Attributes reference · Actions · SSE events
- Examples — active search, click-to-edit, lazy load, bulk update, …
- Official SDKs exist for many languages (incl. PHP) and are just SSE formatters — raw
text/event-streamlike in this tutorial is always valid.