Cookbook
Task-shaped recipes — each one a complete, runnable page (the exact sources below run in CI on every commit). The modes line says where it applies: client, SSR, prerender.
Auth + sessions
ssr
Email/password sessions with zero config: one auth line in spark.json, plain HTML forms for sign-up/in/out, and the ambient {session} in server templates. Works with JavaScript disabled.
pages/index.html
<template if="session.id">
<h1>Welcome back, {me.email}</h1>
<form action="/api/logout" method="post"><button>Sign out</button></form>
</template>
<template else>
<h1>Sign in</h1>
<form action="/api/users?auth" method="post">
<input type="email" name="email" required placeholder="you@example.com">
<input type="password" name="password" required>
<button>Sign in</button>
</form>
<h2>New here?</h2>
<!-- a plain create on the auth table IS sign-up; the password hashes on the way in -->
<form action="/api/users" method="post">
<input type="email" name="email" required placeholder="you@example.com">
<input type="password" name="password" required minlength="8">
<button>Create account</button>
</form>
</template>
<!-- declaring the auth table creates it — the template is the schema; the
seed gives you a demo login (passwords hash on the way in) -->
<spark-ssr table="users" seed="./seed/users.json" />
<spark-ssr>
me = SELECT id, email FROM users WHERE id = :session.id
</spark-ssr>
spark.json
{
"db": "sqlite::memory:",
"auth": {
"table": "users",
"identity": "email",
"secret": "ENV.SPARK_TEST_SECRET"
}
}
seed/users.json
[
{
"email": "demo@example.com",
"password": "demo-password"
}
]
CRUD table with search
ssr
One table block gives you schema, seed, and synthesized add/remove handlers. Search uses the ambient {q}: ?q= filters the server render, the same expression live-filters after hydration. Params are always bound — never interpolate ORDER BY.
pages/index.html
<h1>Todos</h1>
<!-- {q} is ambient: ?q=milk filters the SERVER render; the bind live-filters
on the client — one expression, both worlds. Params stay bound (never
string-interpolate ORDER BY). -->
<input bind:value="q" placeholder="Search…">
<input bind:value="draft" placeholder="New todo">
<button onclick={add}>Add</button>
<ul>
<template each="t in todos" key="t.id">
<li :hidden="q && !t.title.toLowerCase().includes(q.toLowerCase())">
{t.title} <button onclick={remove}>×</button>
</li>
</template>
</ul>
<spark-ssr table="todos" seed="./seed/todos.json" />
spark.json
{
"db": "sqlite::memory:"
}
seed/todos.json
[
{
"title": "Buy milk",
"done": false
},
{
"title": "Write the cookbook",
"done": true
}
]
Dark mode / theming
client ssr prerender
theme() (one call in main.js) owns the mode: system-preference default, localStorage persistence, [data-theme] on <html>. Components read the ambient store; CSS does the rest.
page.html
<button class="toggle" @click="theme.toggle()">theme: {theme.resolved}</button>
<script>
const theme = useStore('theme'); // { mode, resolved, toggle, set }
</script>
<style>
:root { --bg: #fff; --text: #111; }
:root[data-theme="dark"] { --bg: #111; --text: #eee; }
.toggle { background: var(--bg); color: var(--text); }
</style>
setup.js
// main.js in a real app
import { theme } from 'spark-html-theme';
theme();
Deploy
client ssr prerender
Client/prerender apps are static files (any host: GitHub Pages, Netlify, nginx). spark-ssr is one Bun process: the Dockerfile below runs on Fly.io/Railway/any container host; set SESSION_SECRET and mount a volume for sqlite + uploads.
Dockerfile
FROM oven/bun:1 WORKDIR /app COPY package.json bun.lock* ./ RUN bun install --frozen-lockfile --production COPY . . ENV NODE_ENV=production # spark-ssr fails hard in production if auth is configured without a secret — # provide it: fly secrets set SESSION_SECRET=… / railway variables set … EXPOSE 3000 CMD ["bun", "x", "spark-ssr", "start", "--port", "3000"]
File upload
ssr
A multipart form posts to a declared api route; the file streams to uploads/ and :file.url binds its served URL into the INSERT. maxBodyMb (default 10) rejects oversized bodies with 413 before they buffer.
pages/index.html
<h1>Upload a photo</h1>
<form action="/api/photos" method="post" enctype="multipart/form-data">
<input name="caption" required placeholder="Caption">
<input type="file" name="file" accept="image/*" required>
<button>Upload</button>
</form>
<ul>
<template each="p in photos" key="p.id">
<li><img :src="p.url" :alt="p.caption" width="160"> {p.caption}</li>
</template>
</ul>
<spark-ssr table="photos" />
<spark-ssr>
photos = SELECT * FROM photos ORDER BY id DESC
POST /api/photos → INSERT INTO photos (caption, url) VALUES (:body.caption, :file.url)
</spark-ssr>
spark.json
{
"db": "sqlite::memory:"
}
Forms + validation
client ssr prerender
bind: for two-way state, a $: derivation for live validation, and a disabled submit until the form is valid. On spark-ssr the same constraint attributes run again on the server (422 + {errors.field}).
page.html
<form @submit="save()">
<label>Name <input bind:value="name" required maxlength="40"></label>
<label>Email <input bind:value="email" type="email"></label>
<template if="emailError">
<p class="error">{emailError}</p>
</template>
<button :disabled="!valid">Save</button>
</form>
<p class="saved" :hidden="!savedAt">Saved at {savedAt}</p>
<script>
let name = '';
let email = '';
let savedAt = '';
$: emailError = email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)
? 'That does not look like an email address.' : '';
$: valid = name.trim() && email && !emailError;
function save() {
savedAt = new Date().toLocaleTimeString();
}
</script>
Offline + PWA
client prerender
spark-html-manifest emits the webmanifest + icons and (offline: true) an app-shell service worker into every built page; spark-html-offline additionally caches CDN component imports so they work on a plane.
page.html
<h1>Installable, offline-first</h1> <p>Nothing app-specific to write: the pipeline injects the manifest link, theme color, and service-worker registration into every built page.</p> <div import="https://esm.sh/some-pkg/card.html"></div>
spark.config.js
import manifest from 'spark-html-manifest/bun';
import offlineSw from 'spark-html-offline/bun';
export default {
pipeline: [
manifest({
name: 'My Spark App',
shortName: 'Spark',
themeColor: '#ffd24a',
icon: 'public/icon.png', // one image → 192 + 512, resized
offline: true, // app-shell worker + auto registration
}),
offlineSw(), // CDN component imports work offline too
],
};
Realtime updates
client ssr prerender
spark-html-websocket turns a socket into a store: declare it like a route, read {prices.data} like any state, prices.send() to publish. Reconnects with backoff; status rides the store.
page.html
<template ws="wss://stream.example.com/prices" store="prices"></template>
<p :hidden="prices.status !== 'open'">● live</p>
<h1>BTC {prices.data?.btc ?? '…'}</h1>
<button @click="prices.send({ subscribe: 'btc' })">Subscribe</button>
<script>
const prices = useStore('prices');
</script>
<script type="module">
import { sockets } from 'spark-html-websocket';
sockets(); // boots every <template ws> on the page
</script>
SEO + social cards
prerender
spark-prerender writes fully-rendered HTML at build time (crawlers see content, not placeholders); spark-html-head keeps <title>/<meta> — including og: cards — in sync per route from one map.
page.html
<h1>Server-grade SEO, no server</h1>
<p>`bun run build` renders this page to static HTML — <code>{expressions}</code>
resolved, meta tags injected — then the client re-renders over it (hydrates).</p>
spark.config.js
import prerender from 'spark-prerender/bun';
export default {
pipeline: [prerender({ site: 'https://example.com' })],
};
main.js
import { mount } from 'spark-html';
import { head } from 'spark-html-head';
head({
title: { '/': 'Home', '/about': 'About', '*': 'Not found' },
titleTemplate: (t) => `${t} · My Site`,
meta: {
description: (path) => `The ${path} page`,
'og:title': (path) => `My Site — ${path}`,
'og:image': () => 'https://example.com/og.png',
},
});
mount();
Testing your app
client ssr prerender
spark-html-test-utils mounts real components in linkedom — no browser: mount a fixture, fire events, settle, assert on the DOM. This recipe IS a runnable test.
counter.test.js
import { mount, fireClick } from 'spark-html-test-utils';
import { strict as assert } from 'node:assert';
const h = await mount({
root: '<div import="counter"></div>',
components: {
counter: '<button onclick={inc}>{n}</button><script>let n = 0; function inc() { n++; }<\/script>',
},
});
fireClick(h.query('button'));
await h.settle();
assert.equal(h.query('button').textContent, '1');
h.cleanup();
console.log('counter.test.js: 1 passed');