Spark documentation
Everything you need to hand-craft web apps with Spark — single-file HTML components with reactivity, props, and shared stores. No compiler, no virtual DOM: the code you write yourself is the code that runs.
The concept
Most modern frameworks place a toolchain between you and the browser: code is compiled from an invented dialect, UI updates flow through a virtual representation of the DOM, and shipping requires a build pipeline. Each layer adds power — and adds something to install, learn, debug, and keep upgraded.
Spark inverts the trade. A component is a plain HTML file the browser receives unmodified. State is ordinary variables; reactivity comes from intercepting assignments, not from re-rendering and diffing a copy of your UI. The runtime patches the real DOM directly and is small enough to read in one sitting. The result: no build step to maintain, nothing proprietary in your source, a mental model that is just HTML plus JavaScript, and components that stay readable in view-source forever.
This is a deliberate scope, not a missing roadmap — Spark targets the very large class of sites and apps that need interactivity, components, and shared state without a compiler's ceiling of complexity.
It's also a deliberate audience. In a world where AI generates ever more of the web, Spark is built for the people who love hand-writing theirs — every abstraction it skips is one you don't have to hand over. The whole surface fits in your head: what you type is what ships, and what ships is what you can read back, byte for byte.
Installation
Scaffold a complete, ready-to-run app (Bun dev server + the Spark runtime + a live welcome screen) in one command:
bunx create-spark-html-app@latest yourapp
Then cd yourapp && bun install && bun dev. Or add Spark to an existing project:
bun add spark-html bun add -d spark-html-bun
Spark is a runtime library plus an optional Bun-powered dev server and build (spark-html-bun). There is no compile step — component files are fetched and booted in the browser.
No build, no install
Because components are fetched at runtime and the runtime is a single ES module, you can use Spark with zero tooling — straight from a CDN with an import map. No install, no bundler, no compiler:
<script type="importmap">
{ "imports": { "spark-html": "https://esm.sh/spark-html@1" } }
</script>
<div import="components/counter"></div>
<script type="module">
import { mount } from 'spark-html';
mount();
</script>
Serve the folder with any static server (bunx serve, python3 -m http.server) and open it — that's the whole toolchain. Components are just files at a URL, so you can even import one straight from a CDN or another repo:
<div import="https://cdn.jsdelivr.net/gh/you/repo/card.html"></div>
This is Spark's sharpest edge: the file you ship is the file that runs, and "install" can be a single <script> tag. A complete runnable example lives in examples/no-build, and the home page's URL import tab fetches a component from a CDN live.
Components
A component is a plain .html file containing markup, an optional <script>, and an optional <style>:
<!-- components/welcome.html -->
<h1>Welcome {name}</h1>
<script>
let name = 'John Doe';
</script>
<style>
h1 { color: rebeccapurple; }
</style>
Use it anywhere with an import placeholder. The path resolves relative to the served root; the .html extension is optional:
<div import="components/welcome"></div>
Every top-level let, const, var, and function in the script becomes reactive component state. Assigning to a variable re-patches the component's DOM — that's the whole reactivity model.
Styles are automatically scoped to the component by a small CSS parser: selectors inside @media and @supports are scoped too (so responsive overrides just work), while @keyframes and @font-face are left untouched. Use :global(selector) to opt out for resets and page-level rules — it works anywhere in a selector, e.g. :global(.theme-dark) .card.
Template syntax
| Feature | Syntax |
|---|---|
| Text binding | <p>Hello {name}</p> |
| Expressions | {price * qty} {ok ? 'yes' : 'no'} |
| Events | <button onclick={add}> — handler receives the DOM event |
| Dynamic attributes | <button :disabled="count >= 10"> — booleans toggle, others set |
| Attribute interpolation | <input value="{draft}"> |
| Loops | <template each="todo in todos">…</template> |
| Loops with index | <template each="todo, i in todos">…</template> |
| Keyed loops | <template each="row in rows" key="row.id">…</template> — reuse nodes by identity across reorders |
| Conditional display | <div :hidden="!show"> |
| Dynamic class | <li :class="done ? 'item done' : 'item'"> |
| Two-way binding | bind:value (text, number, <select>, contenteditable), bind:checked (checkbox), bind:group (radio) |
| Conditional blocks | <template if="show">…</template> — nodes enter/leave the DOM |
| Else branches | <template else-if="…"> / <template else> — chained directly after an if; the first truthy branch renders |
| Async blocks | <template await="promise">…</template> — pending / then / catch |
| Name the awaited value | <template await="load(id)" as="user"> — use {user.name} instead of {await.name} |
| Slots | <slot></slot> <slot name="title"></slot> — project caller content |
| Escape hatch | <pre spark-ignore> — subtree is never patched; literal {braces} survive |
Loops live on <template> so they're valid HTML anywhere — including inside <ul> and <table>. Loops reconcile rather than rebuild — existing rows keep their DOM nodes (and focus, scroll, and CSS transitions) across updates, so you can safely place a bind:value input inside a loop. Add key="…" when items reorder, to reuse nodes by identity.
Literal braces
Spark reads {…} in any text as an expression. To show a literal brace, escape it with a backslash — \{ and \}:
<p>press \{enter\} to submit</p> <!-- renders: press {enter} to submit -->
HTML entities like { do not work — the browser decodes them back to { before Spark sees the text. For a whole block of literal content (code samples, docs), use the spark-ignore attribute so Spark skips the subtree entirely:
<pre spark-ignore>
function greet() { return `Hi ${name}`; }
</pre>
A string expression also works for one-off braces: {'{'} renders {. The backslash escape is usually cleaner in prose.
Conditional chains (if / else-if / else)
Consecutive sibling templates form a chain: one if head, any number of else-if branches, and an optional bare else. Exactly one branch renders — the first whose expression is truthy, or the else when none is:
<template if="score > 90"><p>Excellent</p></template> <template else-if="score > 60"><p>Passed</p></template> <template else><p>Try again</p></template>
Branches must follow each other directly (whitespace and comments between are fine — other content breaks the chain). Expressions short-circuit like real if/else: branches after the active one aren't evaluated. Works inside each rows, nested chains, and with prerender and enter/leave transitions (spark-html-motion) like a plain if.
Async blocks (<template await>)
Render a promise declaratively — loading, resolved, and error states — with no manual flags:
<template await="loadUser(id)">
<p>Loading…</p> <!-- pending -->
<template then> <h1>Hi {await.name}</h1> </template> <!-- resolved -->
<template catch> <p>Failed: {await.message}</p> </template> <!-- error -->
</template>
Inside <template then>, the identifier await holds the resolved value. Inside <template catch>, it holds the error. Give it a custom name with as="…":
<template await="loadUser(id)" as="user">
<p>Loading…</p>
<template then> <h1>Hi {user.name}</h1> </template>
<template catch> <p>Failed: {user.message}</p> </template>
</template>
The expression is reactive — re-evaluated when state changes, cancelling the prior promise. Use await="once(expr)" to fire on mount only (no re-evaluation). A plain (non-promise) value renders the then branch immediately. The spark-prerender build tool waits for the promise to settle before writing the output, so async data lands in the HTML.
Slots
Slots let a component wrap content the caller provides — the key to reusable wrappers like cards, modals, and layouts. A component marks insertion points with the real <slot> element:
<!-- components/card.html --> <article class="card"> <header><slot name="title">Untitled</slot></header> <div class="body"><slot>Nothing here yet.</slot></div> </article>
Whatever you write between the import tags is projected in — targeted by slot="name", with everything else filling the default slot:
<div import="components/card">
<span slot="title">{heading}</span>
<p>Hello {user.name} — you have {count} messages.</p>
<button onclick="{refresh}">Refresh</button>
</div>
Projected content keeps the parent's scope: its {interpolations}, bind:, and onclick handlers resolve where you wrote them, and stay reactive to the parent. A <slot> with no provided content renders its own children as fallback.
Reactive statements & lifecycle
Derive values with $: — they re-run automatically whenever component state changes:
<p>{count} doubled is {doubled} — that's {label}</p>
<script>
let count = 3;
$: doubled = count * 2;
$: label = doubled > 8 ? 'big' : 'small';
</script>
A $: statement can span multiple lines — a ternary, a chained call, or a callback broken across lines all work.
Run code after the component is in the DOM with the onMount builtin. Return a function to register a cleanup hook — it runs when the component leaves the DOM (removed by an if/each block, or via unmount()):
<script>
let data = [];
onMount(async () => {
const id = setInterval(tick, 1000);
data = await fetch('/api/items').then(r => r.json());
return () => clearInterval(id); // cleanup on unmount
});
</script>
Updates are batched: several assignments in one event handler (and all $: recomputations) collapse into a single DOM patch on the next microtask, so a handler that touches ten variables still patches once.
JS imports
Use standard import statements directly inside a component's <script> tag. Spark lifts them out and replays them as dynamic import() calls resolved relative to the component file — no bundler needed:
<!-- components/user-card.html -->
<h2>{greeting}</h2>
<script>
import { capitalize } from '../lib/format.js';
let name = 'spark';
let greeting = `Hello, ${capitalize(name)}!`;
</script>
All import forms work: named (with aliases), default, namespace, and side-effect:
<script>
import utils from '../lib/utils.js'; // default
import { add, multiply } from '../lib/math.js'; // named
import { format as fmt } from '../lib/text.js'; // aliased
import * as api from '../lib/api.js'; // namespace
import '../lib/setup.js'; // side-effect
</script>
Relative (./, ../) and root-absolute (/lib/…) paths resolve against the component file's URL, not the page. Bare specifiers (import confetti from 'canvas-confetti') are left to the browser, so an import map resolves them — still no build step.
Imports work with $:, props, and everything else — imported functions are just regular JavaScript, and imported values live in component state:
<script>
import { multiply, factorial } from '../lib/math.js';
export let a = 6;
export let b = 7;
$: product = multiply(a, b);
$: fact = factorial(a);
</script>
A script with imports runs as an async function, so top-level await works in it too:
<script>
import { fetchGreeting } from '../lib/api.js';
let data = await fetchGreeting('Spark');
</script>
Components stay flash-free: a component with imports is revealed only after its modules load, and mount() resolves when everything is booted. During prerender, imports execute for real (loaded from disk with Node's module loader), so the prerendered HTML contains the actual computed values.
Props
Declare props with export let. The value after = is the default:
<!-- components/profile.html -->
<h2>{name}{admin ? ' · admin' : ''}</h2>
<script>
export let name = 'Anonymous';
export let age = 0;
export let admin = false;
</script>
Pass props as attributes on the import placeholder:
<div import="components/profile" name="Ada Lovelace" age="36" admin></div>
Attribute values are coerced: "36" becomes the number 36, "true"/"false" become booleans, a bare attribute becomes true, and valid JSON (items='["a","b"]') is parsed. Anything else stays a string.
Variables declared with plain let are private — attributes cannot override them. class and id on the placeholder keep their normal HTML meaning and are copied to the component's host element instead of becoming props.
Stores
Stores are named, shared, reactive objects — the way separate components talk to each other. Create them in app code before mounting:
// main.js
import { mount, store } from 'spark-html';
store('cart', { items: [], total: 0 });
mount();
Subscribe from any component with the useStore builtin (available in every component script, no import needed):
<p>{cart.items.length} items — ${cart.total}</p>
<script>
const cart = useStore('cart');
function add(item, price) {
cart.items = [...cart.items, item];
cart.total = cart.total + price;
}
</script>
Every property assignment on a store re-patches all subscribed components. Subscriptions are cleaned up automatically for components no longer in the DOM.
Derived stores
derived(name, deps, compute) is a read-only store computed from other stores — the cross-component answer to a component-local $:. It recomputes when any source changes and only notifies subscribers when a key actually changes (memoized):
// main.js
import { store, derived } from 'spark-html';
store('cart', { items: [] });
derived('cartTotal', ['cart'], (cart) => ({
count: cart.items.length,
total: cart.items.reduce((s, i) => s + i.price, 0),
}));
Read it like any store. compute returns an object whose keys become the derived state; derived stores may even depend on other derived stores. Mutate a source store, never the derived proxy.
<p>{summary.count} items — ${summary.total}</p>
<script>const summary = useStore('cartTotal');</script>
Forms
bind:form="name" on a <form> creates a reactive name object — { valid, errors, values, pending, submitted, error } — driven by native HTML constraint validation (required, type="email", pattern, minlength…). Submit is auto-preventDefault'd; an async onsubmit handler is awaited with pending and a rejection is caught into error. No manual flags:
<form bind:form="f" onsubmit={save} novalidate>
<input name="email" type="email" required bind:value="email" />
<p :hidden="!(f.submitted && f.errors.email)">{f.errors.email}</p>
<button type="submit" :disabled="f.pending || !f.valid">
{f.pending ? 'Saving…' : 'Sign up'}
</button>
<p :hidden="!f.error">✗ {f.error?.message}</p>
</form>
<script>
let email = '';
async function save() { await api.signup(email); } // f.pending wraps this
</script>
A plain onsubmit={…} on a <form> (without bind:form) is auto-preventDefault'd too — no accidental full-page navigation.
Async data
For data fetching, the optional spark-html-query package adds a self-fetching store on top of store() — loading / error / data / fetching / refetch / mutate — read with the same useStore:
// main.js
import { query } from 'spark-html-query';
query('user', () => fetch('/api/user').then((r) => r.json()));
<p :hidden="!user.loading">Loading…</p>
<h1 :hidden="user.loading">{user.data?.name}</h1>
<button onclick="{user.refetch}">Reload</button>
<script>const user = useStore('user');</script>
It pairs with derived() — shape a query into exactly what a view needs, memoized, and it updates as the request settles.
JavaScript API
| Export | Description |
|---|---|
mount(root?) | Resolve imports and boot all components. root is a selector or element; defaults to document.body. Returns a promise. |
unmount(el) | Tear down a mounted subtree: run its onMount cleanups and drop its store subscriptions. Call before removing a component you mounted imperatively. |
store(name, initial?) | Create (or retrieve) a named store. Returns the reactive store object. |
derived(name, deps, compute) | Create a read-only store computed from other stores (by name). Recomputes on source change, memoized per key. Returns the derived store object. |
component(name, source) | Register a component from a source string instead of a file. <div import="name"> then resolves to it — useful for tests and inline components. |
parseSFC(source) | Split component source into { markup, script, style }. |
evaluate(expr, scope) / interpolate(tpl, scope) | The low-level expression engine, exported for testing. |
subscribe(name, fn) | Subscribe to a named store; fn runs on every change. Returns an unsubscribe function. The primitive companion packages build on. |
scopeCss(css, tag) | Scope a stylesheet to a component tag the way the runtime does for a component's <style> — the transform, exposed for tooling. |
lifecycle(hooks) | Register enter/leave hooks that fire as if/each add and remove nodes — the seam animation libraries (e.g. spark-html-motion) hook into. |
inspectStores() | A live snapshot of every named store's state ({ name: state }) for tooling — what spark-html-devtools reads. |
inspect ⚠︎ | Experimental. inspect.deps(node) → a node's tracked dependency keys (Set or null); inspect.scope(el) → a component's reactive scope proxy. The supported window onto the __spark* internals every debugging session uses. Shape may change in a 1.x minor until it settles. |
Inside component scripts, the builtins useStore(name) and props (the raw props object) are always in scope.
Dev server & build
// package.json
{
"scripts": {
"dev": "spark dev", // Bun dev server + scoped component HMR
"build": "spark build", // bundle the app shell + run the pipeline
"preview": "spark preview" // serve dist/ like the deploy targets do
}
}
// spark.config.js — optional; every field has a default
import prerender from 'spark-prerender/bun';
export default {
base: '/', // deploy prefix (GitHub Pages: '/repo/')
componentsDir: 'components', // where your .html fragments live
pipeline: [prerender()], // post-build steps, in order
};
spark-html-bun serves component fragments raw (no HMR script injected into them) and hot-reloads only the edited component, siblings' state preserved. The build bundles your app shell with Bun and runs the pipeline over dist/. Spark itself has no hard dependency on it — any static file server works.
Bare imports inside component scripts (import { store } from 'spark-html') work identically in dev and in the built app: spark build scans every shipped .html for bare specifiers, copies each used package to assets/modules/<pkg>@<version>/, and emits the same import map dev uses — mapped packages stay external to the bundle, so the whole page shares one module instance (never a second copy of the runtime). Pages with no bare component imports build byte-identically to before.
Editor support
Spark components are plain .html files, so they syntax-highlight out of the box. For full {interpolation} highlighting there are extensions for Zed and VS Code. spark-html-language-server adds live diagnostics as you type — undefined bindings, unstable prop names, each without key, and (1.4.0) directive typos with the suggested fix (:clas → :class) — the same checks the in-browser dev diagnostics run.
Format on save (Zed)
Spark mixes HTML-style attributes (:value="x", onclick="{fn}") with {interpolations}, and the stock Prettier html/svelte parsers both corrupt that. So Spark ships prettier-plugin-spark — it formats the <script> and <style> blocks and leaves your markup byte-for-byte intact, so {…} and onclick="{…}" are never mangled.
The Zed extension already declares the parser; enable Prettier + the plugin once in your Zed settings.json (Zed bundles Prettier and installs the plugin for you), and format-on-save just works:
{
"languages": {
"Spark": {
"prettier": {
"allowed": true,
"plugins": ["prettier-plugin-spark"]
}
}
}
}
Outside Zed, add the plugin to your .prettierrc with a *.html override that sets "parser": "spark", then run bunx prettier --write.
How it works
1 — Text-level parsing. When a component is fetched, <script> and <style> are extracted from the raw text before the markup ever touches innerHTML. Browsers neuter script tags injected via innerHTML, so DOM-based extraction is unreliable by design — Spark sidesteps the whole class of bugs.
2 — Proxy scopes. The script's top-level declarations are collected, the declarations are rewritten to bare assignments, and the code runs inside with(proxy). Every assignment hits the proxy's set trap, which re-patches that component's subtree. Objects and arrays read from scope come back wrapped in a thin reactive proxy, so in-place mutation (todos.push(x), row.done = true) re-renders too — only plain objects/arrays are wrapped, so Dates and class instances are left alone. Window built-ins like name or status can't shadow your state — the scope only claims identifiers your script declared.
3 — In-place patching. Text nodes and attributes cache their original template (and parsed bindings) on first visit, then diff against the interpolated result on every patch. Loops keep one block of nodes per item and reconcile them — reusing nodes in place (by index, or by key when given), creating only what's new and removing only what's gone. No virtual DOM and no full-tree diff — just direct, scoped writes against the real nodes, batched onto a single microtask flush.
4 — Only what changed. A patch does work proportional to the changed state, not the size of the component. Static subtrees (no bindings, loops, conditionals, or nested components) are walked once and then skipped entirely. And while each binding and $: statement is evaluated, the proxy records which scope keys it read — so a plain count++ re-evaluates only the bindings and reactive statements that read count. That precision reaches into loops: mutating one row's data (todos[i].done = true) re-walks just that row, not the whole list — a 1,000-row table where one cell changes does one row's work, not a thousand, while a $: aggregate over the list and any direct out-of-loop read stay correct. Changes that still can't be pinned to a row or key — a structural array change (todos.push), a non-loop deep mutation, store notifications, and member-path two-way writes — fall back to a full (still cheap) pass, so the UI is never stale.
5 — No flash on load. A cloak style hides Spark-managed elements until each component is booted, styled, and patched — so the raw {braces} and unstyled markup never flash before the runtime catches up.
Performance
Measured, not claimed. Spark runs the krausest js-framework-benchmark — the industry-standard table every major framework is measured on — against the hand-written vanillajs reference implementation. Spark 1.8.0's numbers, from a paired run in one session (25 iterations, windowed Chrome, official webdriver-ts harness, same machine for both frameworks):
| Benchmark | vanilla (ms) | spark (ms) | ratio |
|---|---|---|---|
| create 1,000 rows | 102.1 | 121.5 | 1.19× |
| replace 1,000 rows | 110.9 | 131.9 | 1.19× |
| update every 10th (×16) | 51.0 | 57.3 | 1.12× |
| select row | 11.6 | 13.7 | 1.18× |
| swap rows | 60.0 | 75.5 | 1.26× |
| remove one | 53.7 | 62.4 | 1.16× |
| create 10,000 rows | 1,127.6 | 1,374.5 | 1.22× |
| append 1,000 | 118.3 | 145.8 | 1.23× |
| clear (×8) | 35.5 | 39.6 | 1.12× |
CPU geomean: 1.185× vanilla — and run memory holds ~1.45× vanilla. Mutating state in place (rows[i].label += '!', rows[1] = x) rides the same narrow dirty-key lane as reassignment — the idiomatic style is the fast path (update-every-10th: 1.36× → 1.12×). On the scale solidjs.com publishes from the same benchmark, that is past Vue (1.31) and past Angular (1.45) — the reference numbers are cross-machine, so the caveat still attaches — and Spark is the only framework in that neighborhood that ships your HTML untouched: no compiler, no build step, nothing generated. (Per-op numbers wobble in both directions across runs — clear has read 0.96–1.25× across runs on the same code; remove has read 1.05–1.33 on the same code. The geomean is the currency.) First paint sits at parity with vanilla — we previously published a 0.86× first-paint headline and retired it when more samples showed that metric swings ±20% per run; the A/B against the prior release read 9 ms faster by medians, which we still report as parity. (Honesty notes: this is our local paired run against vanilla on one machine; the upstream submission (PR #2048) is MERGED — spark-html is listed in the official js-framework-benchmark; upstream-published chrome numbers land with their next results run. Method, raw medians, the first-paint audit trail, and the previous runs live in the repo's benchmarks.md.)
Where the speed comes from: keyed reconciliation that trims the unchanged prefix/suffix and diffs the rest with a longest-increasing-subsequence pass (a swap is 2 DOM moves, not 997), the template's observed dependency graph dispatching a changed key straight to the affected bindings in every row (no per-row bookkeeping at all) — with selection-shaped bindings (key === selected) resolved through a keyed index that touches exactly two rows, not a thousand — rows created 64 at a time from one native clone of a stamped recipe, document-level event delegation for rows (creating 1,000 rows allocates zero listeners and zero handler closures), and a runtime that warms its own row pipeline right after first paint — before your first interaction — so the first real 1,000-row operation runs JIT-warm instead of paying optimizing-compiler time mid-click. All of it internal: five speed programs added zero new concepts and zero API surface, inside a size budget that grew deliberately, is itemized per release, and has been frozen at 18.00 KB since 1.2 — now used to the byte (18,432 of 18,432).
Debugging
When something doesn't render, check the console — Spark warns (prefixed [spark]) instead of failing silently:
- A throwing
{expression}renders empty and warns with the exact expression (use{a?.b}for values that may be missing). - A syntax error in an expression or
<script>names the offending code; a failed<script>tells you the component's state and handlers are unavailable. - A malformed
each, aneachover a non-array, or a missingimporteach warn with the likely fix.
Warnings are deduplicated — each distinct problem is reported once, so a broken binding won't flood the console on every keystroke.
When something goes wrong — the dev diagnostics layer
In dev mode (spark dev, or a spark-ssr server in watch mode) the page is automatically given a diagnostics module from spark-html-devtools. It never ships in production output, costs the core zero bytes, and each message names its fix — paste any of these into a search and you land here:
- unknown directive
`:clas`— did you mean`:class`? A static scan flags attributes one edit away from a known directive (:attr/@event/bind:value/ templateeach·if·else-if·else·await·then·catch·key·as). Anything else — a custom event, an arbitrary:data-*bind — is deliberately never flagged. (If it's intentional, ignore this; only near-misses of known names are flagged.) Your editor shows the same check viaspark-html-language-server. - duplicate spark-html detected — two runtimes each own a private store registry, which surfaces as "store not created". The console error is escalated to an in-page banner. Fix: run
npx spark-html doctor, then delete node_modules + the lockfile and reinstall so one copy remains. - hydration mismatch at
<path>— the server-rendered HTML and the settled client DOM disagree at that node. If the value there is computed in the page's own<script>: SSR never runs a page's own <script> — compute this field in the MODULE data source instead. (Content the client legitimately adds — anawaitblock resolving — is never flagged; changed or lost content is.) - [spark-ssr] … server-side startup warnings (schema inference notes, config problems) are mirrored into the browser console in dev, so a warning that only printed to server stdout is no longer invisible.
The production escape hatch is npx spark-html doctor: duplicate-install scan, companion range checks, "latest"-pin detection, and the stale-service-worker heuristic. The diagnose module is the dev-time half; doctor is the anytime half.
Error handling
Failures are isolated to the component that caused them — a broken component never blanks the page or stops a sibling from rendering. Broken expressions, $: statements, event handlers, a <script> that throws, boot, and patch are all caught and logged (deduped) with the component named.
For development, opt into a full-screen error overlay (message, failing component, and stack) — off by default:
import { mount } from 'spark-html';
mount(document.body, { devOverlay: true });
Prerender for SEO
Spark renders on the client, so a crawler that doesn't run JS sees empty placeholders. The companion spark-prerender package runs the real runtime against a server DOM and writes back fully-rendered HTML — {interpolations} resolved, each/if and nested imports rendered, scoped styles inlined, and <title>/<meta> injected from component state. The output keeps its import placeholders, so the client re-renders over it (hydrates) with no blank.
# post-build step — multi-page is just a list (no router) bunx spark-prerender dist/index.html dist/docs.html
Metadata needs no special API — declare it as component state, and async data lands in the HTML via an async load() hook:
<script>
let pageTitle = 'Spark — HTML that reacts!';
let pageDescription = 'Single-file HTML components with built-in reactivity.';
let photos = [];
async function load() { photos = await (await fetch('/api/photos')).json(); }
</script>
There's also a build step (spark-prerender/bun) that prerenders dist/*.html automatically on spark build. See the package README for options. Need per-request or per-user pages (auth, live data, a database) rather than build-time HTML? That's spark-ssr.
Full-stack: spark-ssr
Prerender is the static answer to SEO; spark-ssr is the dynamic one — a complete server-rendered framework where the same Spark markup is the whole app. There's no separate backend to write: the HTML template is the spec. Declare the data a page needs in a <spark-ssr> block and the server infers the rest — the database schema, a REST API, routes from filenames, auth from a user_id column — then renders the page on the server (real HTML for crawlers) and hydrates it in the browser with the same runtime you already know.
bunx create-spark-html-app@latest myapp # choose "SSR" cd myapp && bun install && bun dev
The template is the backend
A page is an HTML file under pages/. Add one line and the CRUD backend exists — no SQL, no migrations, no server file:
<!-- pages/index.html -->
<template each="todo in todos">
<label>
<input type="checkbox" bind:checked="todo.done" onchange={toggle}>
{todo.title}
<button onclick={remove}>✕</button>
</label>
</template>
<input bind:value="draft" placeholder="New task">
<button onclick={add}>Add</button>
<spark-ssr table="todos" live />
From that single declaration, spark-ssr infers a todos table from how the template reads it (title becomes TEXT, done becomes INTEGER) and creates it; generates GET/POST/PATCH/DELETE /api/todos; renders the rows to HTML on the server; and hydrates the page so it's interactive — no <script>, no fetch plumbing. With live, every write pings a server-sent event stream and every open tab refetches, so the list stays in sync in real time.
Any data source
A page's data doesn't have to be a table. Name a variable in the block and point it at SQL, a URL, a markdown glob, or a JS module — the page renders and hydrates the same way, database or not:
<spark-ssr> posts = SELECT * FROM posts WHERE published = 1 repo = https://api.github.com/repos/you/proj <!-- fetched server-side --> docs = ./content/*.md <!-- markdown blog, zero database --> weather = ./lib/weather.js <!-- (req, db) => value — bring any ORM --> </spark-ssr>
Ambient helpers — less script
When a page needs custom logic, its <script> becomes the client component — and four helpers are in scope with no imports and no fetch boilerplate. Your state is seeded from the page's data, and any handler your template references but you don't write is synthesized for you:
<input bind:value="title"><button onclick={create}>Save</button>
<template each="p in posts">
<button onclick={toggle(p)}>{p.published ? 'Unpublish' : 'Publish'}</button>
<button onclick={remove(p)}>Delete</button> <!-- synthesized -->
</template>
<script>
async function create() {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
await api_create({ slug, title }); // POST /api/posts
title = ''; refresh(); // refetch every declared source
}
async function toggle(p) {
await api_update(p.id, { published: p.published ? 0 : 1 });
refresh();
}
</script>
<spark-ssr table="posts" />
The helpers are api_create(body), api_update(id, body), api_delete(id), and refresh(). More than one table on a page? Pass it explicitly: api_create('posts', {…}). Want none of it — write your own fetch and don't set table=; that's the classic escape hatch.
Same-page links — navigate()
A hydrating page often has links or buttons that only change its own query string — picking a row in a list, paging, switching a tab. A plain <a href="?with=2"> works with no JavaScript, but on a hydrated page it does a full document reload for a change refresh() could apply in place. Wire navigate as a click handler on a container and it takes over: same-origin, same-path clicks get intercepted, the URL updates via history.pushState, and refresh() reloads just the data — no navigation, no flash. Cross-page and cross-origin links fall through untouched, so it's additive, not a router.
<nav onclick={navigate}>
<template each="c in contacts">
<a href="/?with={c.id}">{c.name}</a>
</template>
</nav>
That's it — no <script> needed for the common case. navigate is ambient on any page with at least one data source, the same way refresh and the api_* helpers are; it also wires a popstate listener so browser back/forward reloads the right data. Two things worth knowing: it only fires for links whose pathname matches the current page — a link to a different route always does a real navigation, so navigate never risks masking an actual page change as a data refresh. And it's scoped to the page that declares the data — an imported <div import="/components/…"> has its own isolated scope and can't call the host page's navigate/refresh; keep query-driven nav in the page itself (or a layout, which shares page scope) rather than a shared component.
Batteries included
The rest of a real app is declared the same way — in the markup:
- Auth — email/password sessions inferred from a
user_idcolumn, plus built-in/login,/signupand/logoutpages with zero UI written. - Guards —
<spark-ssr guard="session" redirect="/login" />protects a page in one line. - No-JS forms — a plain
<form action="/api/…">works without JavaScript (303 back on success), with the markup's constraint attributes validated server-side. - Relations —
each="c in post.comments"infers acommentstable with apost_idforeign key and joins it — no SQL join in the template. - Jobs & mail —
<spark-ssr job="digest" every="1d" />oron="insert:orders"runs a module on a schedule or after a write;mail()is wired from config. - Free extras —
sitemap.xml,robots.txt, OpenAPI (/__spark/openapi.json) and a typed client, all generated from the inferred backend.
Fast by default
Every page and component is parsed once and compiled into a render program — a request is a loop that emits strings, with no DOM built and no HTML re-parsed. In production, anonymous GETs of pages whose output depends only on the URL are served from an auto-detected full-page response cache (invalidated by the same write hooks that power live; tune with "responseCache" in spark.json), big list pages stream, relation loops batch into one IN (…) query, and every cache is bounded. Nothing to configure.
Deploy with bun spark-ssr build (a dist/ plus a compiled single binary, and optionally a Dockerfile). The full reference lives on the npm page.
Prerender vs SSR. Reach for spark-prerender when the content is known at build time (marketing, docs, a blog you rebuild) — it needs no running server. Reach for spark-ssr when pages are per-request or per-user (dashboards, auth, live data) — one server renders the page and serves the API.
Ecosystem
The core stays tiny on purpose; everything else ships as a small, single-purpose sibling package. Add only what you use — each one is documented on its npm page:
| Package | What it does |
|---|---|
spark-html | The runtime — components, reactivity, stores, forms, scoped styles. 18.00 kB gzip, 0 deps. |
spark-html-bun | Dev server, bundler & preview on Bun — scoped HMR, no-build dev, post-build pipeline. |
spark-html-router | <template route> routing — nested routes/layouts, route.query, active links. |
spark-html-theme | Dark/light/system theming in one line — persisted, no flash. |
spark-html-head | Reactive <title>/<meta> per route + a head store. |
spark-html-motion | Enter/leave transitions on if/each blocks — transition="fade|slide|scale". |
spark-html-devtools | In-page devtools — live stores, component tree, patch activity. |
spark-html-query | Declarative async data — a self-fetching store (loading/error/data/refetch). |
spark-html-persist | Persist stores to localStorage/sessionStorage in one line. |
spark-html-websocket | A WebSocket as a reactive store — auto-reconnect, JSON, send(). |
spark-prerender | Build-time SEO prerender + sitemap/robots — no SSR server. |
spark-ssr | Full-stack SSR framework — the template is the backend: inferred DB, REST, auth, jobs. Docs ↑ |
spark-html-image | Build-time image optimization — webp/avif + responsive srcset, zero config. |
spark-html-font | Font loading optimizer — preload + size-adjusted fallbacks, no FOUT. |
spark-html-manifest | PWA manifest + icons + head tags (and optional service worker) from one config. |
spark-html-offline | Offline URL imports — a service worker that caches CDN components. |
spark-html-sri | Subresource Integrity — hash + verify assets and remote components. |
create-spark-html-app | Scaffold a spark-html app in one command. |
prettier-plugin-spark | Prettier for components — formats <script>/<style>, markup stays byte-for-byte. |
spark-html-language-server | LSP — diagnostics, go-to-definition, prop autocomplete, hover docs. |
spark-html-test-utils | Component tests without a browser — mount on linkedom, fire events, settle, assert (see the cookbook's "Testing your app"). |
Limitations
Spark trades completeness for simplicity. Know the edges:
| Limit | Detail |
|---|---|
| One scope per component | All top-level declarations share the component scope. JS import statements are supported (transformed to dynamic imports); import.meta is not available inside component scripts. |
| Block scoping | Only top-level let/const declarations become component state. Inside a function body they are true block-scoped locals, and destructuring declarations stay local everywhere. |
| Reactivity depth | Plain objects/arrays are deeply reactive, and Map/Set mutations (set/add/delete/clear) are tracked too. Class instances and Date are not — reassign the variable to update. |
| Loop reconciliation | Reuse is positional by default; reordering a list without a key keeps nodes in place and rewrites their contents. Add key="…" for identity-stable moves. |
| The rewriter is a scanner, not a parser | It is string- and comment-aware: code-shaped text inside string literals stays byte-intact. The one construct it cannot parse is a regex literal containing a quote character — that case warns loudly and names the fix (move the regex to an imported .js module). Display-only samples in markup are fine under spark-ignore. |
| Coarse store updates | Stores are deeply reactive (an in-place cart.items.push(x) notifies every subscriber), but each notification re-renders subscribers with a full (cheap) pass — store reads aren't tracked per key the way component-local state is. For shared computed values, derived() recomputes once and only notifies when a key actually changes (memoized). |
| Hydrating SSR pages never run their script on the server | On an interactive spark-ssr page (handlers or binds), the page's own <script> becomes the client component and executes on the client only — the server renders from the data sources alone. Compute display fields (formatted dates, counts, derived text) in the data source (SQL expression or module source), or the first paint won't have them. A non-interactive page keeps the escape hatch: its plain <script> runs on the server. By design, permanent. |
{session} not ambient in hydrating client scripts | {session} and {path} are ambient in server-rendered template expressions and server-only <script>s. A hydrating page's client component script runs client-side only and does not have session in scope — read the user's id from a declared data source (e.g. me.id) instead. |
| SSR response cache & writes outside the server | In production, spark-ssr's full-page cache is invalidated by writes through the server (its API or SQL routes). A row changed directly in the database by another process stays invisible on cached public pages until the TTL lapses (60 s default — set "responseCache" in spark.json, or false to disable). |
| Live channel fanout is per-process | live broadcasts a write to every open tab over one server process's SSE connections; benchmarked reliable (zero drops) to 5,000 concurrent subscribers, with tail latency growing with subscriber count on a single instance (~16 ms p99 at 200 subscribers, ~216 ms at 2,000). For tighter fanout latency at higher concurrency, run multiple server instances — each handles its own share of subscribers, so tail latency drops roughly with instance count. |
Known issues
Open bugs we're tracking — distinct from the design limitations above (those are permanent; these are defects with a workaround, being fixed):
bind:value inside a hidden container loses its value (unconfirmed) | Reported from a real app port: a bind:value input nested inside a display:none-toggled container lost its value on toggle. We could not reproduce it on 1.7.0–1.8.1 (client-only, SSR-hydrated, layout-hydrated, and template if-nested shapes all keep the value), and the correct behavior is now pinned by a permanent test. If you hit it, please share a minimized repro. Workaround if affected: keep bound inputs outside the toggled container. |
Interpolated width/height on <svg> cleared on hydration (unconfirmed) | Reported from a real app port: bound width/height on a raw <svg> came back empty after hydration. Not reproducible on 1.7.0–1.8.1 (client, prerender-style, and SSR hydration all keep the attributes); the correct behavior is pinned by a permanent test. If you hit it, please share a minimized repro. Workaround if affected: size a wrapper via interpolated style, keep the <svg> at 100%. |
{expr} in numeric SVG attributes logs parse errors | Templates are parsed via innerHTML before interpolation, and browsers validate SVG length attributes (width, height, r, …) at parse time — so <svg width="{size}"> logs a console error like Expected length even though the value is patched correctly afterwards. Cosmetic, but noisy. Workaround: keep SVG numeric attributes literal and size via CSS, or put the expression in style (CSS ignores invalid values silently). |
| OpenAPI / typed-client output is incomplete (experimental) | The generated /__spark/openapi.json reports empty components, documents inserts as 200 rather than 201, and client.ts methods are untyped — it doesn't reflect the real inferred schema. This surface is experimental (may change in a 1.x minor); low priority. |
A non-interactive page's <script> can't use import or useStore | spark-ssr only hydrates a page (giving its <script> real ES-module/client semantics) when the page — or any layout wrapping it — has at least one real onclick/bind:/:checked. Without one, the script runs server-side as a plain function instead, where a static import throws a syntax error and useStore is undefined. Because a layout's script is glued onto every page it wraps, a layout script that imports something or calls useStore can 500 any currently-handler-free page that uses it. Workaround: give the page one real handler (a bare onclick={fn} — a call with a literal argument like onclick={fn('en')} does not count) so it hydrates, or, if it must stay a plain form-post page, avoid import/useStore there and derive what you need server-side instead (e.g. from a request header). |
A hydrated <form redirect="…" flash="…"> loses both attributes | These compile to hidden _redirect/_flash inputs, but only in the server-side SSR renderer — the separate client hydration compiler doesn't know about them, so mounting an interactive page's client component replaces the form with a version missing those hidden fields. Verified directly: the same login form redirects to its declared redirect= target when the page is non-interactive, and to / (the built-in fallback) once it gains a handler and hydrates. Workaround: a page using redirect=/flash= must stay fully non-interactive (no onclick/bind: of its own, and none inherited from its layout) for the attributes to survive past first paint. |