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');