| name | datastar |
| description | Use when building web applications with Datastar — the hypermedia framework that drives frontend reactivity from the backend using HTML data-* attributes and Server-Sent Events. Triggers on Datastar, data-star, SSE-driven UI, hypermedia framework, backend-driven frontend, data-signals, data-on, PatchElements, or any Go/Python web app using Datastar SDKs. |
| metadata | {"author":"danmestas"} |
Datastar — Hypermedia-First Reactive Framework
Datastar is a lightweight framework for building reactive, real-time web applications by driving frontend logic and state from the backend using HTML data-* attributes and Server-Sent Events (SSE). No client-side JavaScript framework required.
Core idea: The backend sends HTML fragments and state updates via SSE. The frontend declares behavior with data-* attributes. No virtual DOM, no build step, no npm.
How It Works
Browser Server (Go)
│ │
│ data-on:click="@get('/api')" │
│ ──────────────────────────────> │
│ │ ReadSignals(r, &store)
│ │ sse := NewSSE(w, r)
│ SSE: datastar-patch-elements │ sse.PatchElements(html)
│ <────────────────────────────── │ sse.PatchSignals(json)
│ │
│ DOM morphs, signals update │
- User interacts with element →
data-on fires action (@get, @post, etc.)
- Server reads signals from request, processes business logic
- Server streams SSE events: patch DOM elements, update signals, execute scripts
- Browser morphs DOM (only changed parts), updates reactive signals
Go SDK Quick Start
package main
import (
"fmt"
"net/http"
"github.com/starfederation/datastar-go/datastar"
)
type Store struct {
Count int `json:"count"`
}
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "index.html")
})
http.HandleFunc("/increment", func(w http.ResponseWriter, r *http.Request) {
store := &Store{}
if err := datastar.ReadSignals(r, store); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
store.Count++
sse := datastar.NewSSE(w, r)
sse.PatchElements(
fmt.Sprintf(`<span id="count">%d</span>`, store.Count),
)
sse.MarshalAndPatchSignals(store)
})
http.ListenAndServe(":8080", nil)
}
<html>
<head>
<script type="module" src="https://cdn.jsdelivr.net/npm/@starfederation/datastar"></script>
</head>
<body>
<div data-signals:count="0">
<span id="count">0</span>
<button data-on:click="@get('/increment')">+1</button>
</div>
</body>
</html>
Go SDK API
Reading Client State
store := &MyStore{}
if err := datastar.ReadSignals(r, store); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
Signals come from query params (GET) or JSON body (POST/PUT/DELETE). Struct tags use json:"fieldName".
Creating SSE Stream
sse := datastar.NewSSE(w, r)
Patching DOM Elements
sse.PatchElements(`<div id="content">Hello</div>`)
sse.PatchElements(`<p>New content</p>`,
datastar.WithSelectorID("container"),
datastar.WithModeInner(),
)
sse.PatchElements(`<li>New item</li>`,
datastar.WithSelector("ul#items"),
datastar.WithModeAppend(),
)
sse.PatchElements(html, datastar.WithModePrepend())
sse.PatchElements(html, datastar.WithSelector("#target"), datastar.WithModeBefore())
sse.PatchElements(html, datastar.WithSelector("#target"), datastar.WithModeAfter())
Merge modes: WithModeOuter() (default), WithModeInner(), WithModeAppend(), WithModePrepend(), WithModeBefore(), WithModeAfter()
Updating Client Signals
sse.MarshalAndPatchSignals(store)
sse.PatchSignals([]byte(`{"count": 42}`))
Removing Elements
sse.RemoveElements("#temporary")
sse.RemoveElement("#single-element")
Executing JavaScript
sse.ExecuteScript(`console.log("Hello from server")`)
sse.ExecuteScript(`import {toast} from './notify.js'; toast("Done!")`,
datastar.WithExecuteScriptAttributes(`type="module"`),
)
Redirecting
sse.Redirect("/new-page")
Connection State
if !sse.IsClosed() {
}
HTML Data Attributes Reference
Signals & State
| Attribute | Purpose | Example |
|---|
data-signals | Define/patch reactive signals | data-signals:count="0" |
data-computed | Read-only derived signal | data-computed:total="$price * $qty" |
data-bind | Two-way binding to input | <input data-bind:name /> |
data-ref | Reference to DOM element | data-ref:myDiv → $myDiv.tagName |
Rendering
| Attribute | Purpose | Example |
|---|
data-text | Bind text content | data-text="$message" |
data-show | Toggle visibility | data-show="$isVisible" |
data-class | Toggle CSS classes | data-class:active="$isActive" |
data-attr | Set any HTML attribute | data-attr:disabled="$loading" |
data-style | Set inline styles | data-style:color="$theme" |
Events & Actions
| Attribute | Purpose | Example |
|---|
data-on | Event listener | data-on:click="@post('/save')" |
data-on-intersect | Viewport intersection | data-on-intersect="@get('/lazy')" |
data-on-interval | Recurring timer | data-on-interval__duration.5s="@get('/poll')" |
data-init | Run on load | data-init="@get('/initial-data')" |
data-effect | Run on signal change | data-effect="console.log($count)" |
Backend Actions
<button data-on:click="@get('/api/data')">Load</button>
<button data-on:click="@post('/api/save')">Save</button>
<button data-on:click="@put('/api/update')">Update</button>
<button data-on:click="@delete('/api/remove')">Delete</button>
All actions automatically send current signals to the server.
Loading Indicators
<button data-on:click="@get('/slow')"
data-indicator:loading
data-attr:disabled="$loading">
<span data-show="!$loading">Submit</span>
<span data-show="$loading">Loading...</span>
</button>
Event Modifiers
<input data-on:input__debounce.300ms="@get('/search')" data-bind:query />
<div data-on:scroll__throttle.100ms="handleScroll()"></div>
<div data-on-intersect__once="@get('/lazy-load')"></div>
<form data-on:submit__prevent="@post('/save')"></form>
<div data-on:keydown__window="handleKey(evt)"></div>
Persistence
<div data-persist></div>
<div data-persist="{include: /theme|lang/}"></div>
<div data-persist__session></div>
Patterns
Search with Debounce
<input data-bind:query
data-on:input__debounce.300ms="@get('/search')"
data-indicator:searching
placeholder="Search..." />
<div data-show="$searching">Searching...</div>
<div id="results"></div>
func handleSearch(w http.ResponseWriter, r *http.Request) {
store := &struct{ Query string `json:"query"` }{}
datastar.ReadSignals(r, store)
results := searchDB(store.Query)
sse := datastar.NewSSE(w, r)
sse.PatchElements(renderResults(results),
datastar.WithSelectorID("results"),
datastar.WithModeInner(),
)
}
Infinite Scroll
<div id="list">
<div>Item 1</div>
</div>
<div data-signals:offset="1"
data-on-intersect__once="@get('/more')"
id="load-more">
Loading more...
</div>
func handleMore(w http.ResponseWriter, r *http.Request) {
store := &struct{ Offset int `json:"offset"` }{}
datastar.ReadSignals(r, store)
sse := datastar.NewSSE(w, r)
sse.PatchElements(
fmt.Sprintf(`<div>Item %d</div>`, store.Offset+1),
datastar.WithSelectorID("list"),
datastar.WithModeAppend(),
)
if store.Offset+1 < maxItems {
sse.PatchSignals([]byte(fmt.Sprintf(`{"offset":%d}`, store.Offset+1)))
} else {
sse.RemoveElements("#load-more")
}
}
Form Submission
<form data-signals:name="''" data-signals:email="''">
<input data-bind:name placeholder="Name" />
<input data-bind:email placeholder="Email" />
<button data-on:click__prevent="@post('/submit')"
data-indicator:saving
data-attr:disabled="$saving">
Save
</button>
</form>
<div id="message"></div>
Live Updates (Polling)
<div data-on-interval__duration.5s="@get('/status')"
id="status">
Checking...
</div>
Tabs
<div data-signals:tab="'home'">
<button data-on:click="$tab = 'home'" data-class:active="$tab == 'home'">Home</button>
<button data-on:click="$tab = 'profile'" data-class:active="$tab == 'profile'">Profile</button>
<div data-show="$tab == 'home'" id="tab-home">Home content</div>
<div data-show="$tab == 'profile'" id="tab-profile">Profile content</div>
</div>
Key Rules
- Read signals BEFORE creating SSE stream.
ReadSignals(r, &store) must come before NewSSE(w, r).
- Elements need IDs for patching. Default merge mode is outer — it matches by element ID.
- Signals are reactive. Changing a signal automatically updates all expressions that reference it.
$ prefix accesses signals. $count reads the count signal. All $name patterns in expressions become signal reads.
- Actions send all signals.
@get('/api') automatically includes all signals in the request.
- Morphing preserves state. DOM updates only change what's different — focus, scroll position, form state are preserved.
- No build step. Single
<script> tag CDN include. No npm, no bundler, no node_modules.
- Backend owns the HTML. Server renders fragments, client morphs them in. This is hypermedia — not JSON APIs + client rendering.
Source: danmestas/wardrobe — distributed by TomeVault.