🐈 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

  1. What is Datastar?
  2. Installation
  3. Events with data-on
  4. Calling the backend with @get()
  5. The PHP backend & Patching Elements
  6. Streaming multiple events with SSE
  7. Reactive signals
  8. Sending signals to PHP
  9. Local signals & data-indicator
  10. Full example: live search

1.What is Datastar?

Datastar is a ~11 KB hypermedia framework that gives you:

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.

The Tao of Datastar: the backend is the source of truth. The frontend is a thin, declarative view over server state. Don't duplicate state client-side, don't do optimistic updates — ask the server and let it patch the DOM (morphing keeps focus, scroll and listeners intact).

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>

Project layout for this tutorial (any PHP server works, e.g. the built-in one):
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.

Modifiers are appended with __: 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>
Every Datastar request carries a 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:

Waiting for an order…

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.

Morph, don't rebuild. Always put an 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();
Waiting…

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:

AttributeWhat 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.

Hello, !
👋 Reactive, without a single line of JS.
The markup behind this demo
<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
}
count = 0

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:

  1. User acts → Datastar sends signals to your endpoint.
  2. PHP reads signals (readSignals()), does the work.
  3. PHP streams back datastar-patch-elements and/or datastar-patch-signals events.
  4. 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);
}
Searching…
Type to search…

What you learned

Where to go next