Full-stack Spark — spark-ssr

Zero config. No build. The HTML template is the backend: spark-ssr reads your pages and infers the schema, the REST API, the validation, the auth wiring, and the hydration — you write markup and (only when you want it) SQL.

The idea

Every other full-stack framework asks you to describe your app twice: once in the UI and again in models, resolvers, route files, or an ORM. spark-ssr reads the single description you already wrote — the template — and derives the rest. <template each="todo in todos"> means you need data called todos; {todo.title} means it has a title column; onclick={add} outside the loop means insert; onclick={remove} inside it means delete.

When inference isn't enough, you drop down one level at a time — a named SQL query, a JS module, a custom endpoint — and everything else keeps working. The escape hatches are doors, not ejection seats.

Quick start

A complete todo app — server-rendered, hydrated, persisted, and live across tabs — is one file:

<!-- index.html -->
<h1>Tasks</h1>

<template await="todos">
  <input bind:value="draft" placeholder="New task">
  <button onclick={add}>Add</button>
  <template each="todo in todos">
    <li>
      <input type="checkbox" bind:checked="todo.done" onchange={patch}>
      {todo.title}
      <button onclick={remove}>✕</button>
    </li>
  </template>
</template>

<spark-ssr table="todos" live />

An await over a promise declared in the page's own <script> (not a data source) passes through untouched on a hydrating page — the server leaves the authored block in place and the client's await machinery resolves it after hydration, exactly like client-only mode (spark-ssr 1.2.0).

bun add spark-ssr
bun spark-ssr

That's the whole app. The todos table is created for you, the CRUD API exists at /api/todos, the page is server-rendered and hydrated, and live keeps every open tab in sync over SSE. No <script>, no SQL, no server file, no build step.

Scaffold the same thing with a blog starter: bunx create-spark-html-app@latest myapp and pick the ssr template.

What gets inferred

The template saysThe framework knows
<template each="todo in todos">You need data called todos
table="todos"It's backed by the todos table
{todo.title}title is a TEXT column
bind:checked="todo.done"done is a boolean column
onclick={add} (outside the loop)POST /api/todos — insert
bind:checked + onchange={patch} (in loop)PATCH /api/todos/:id
onclick={remove} (inside the loop)DELETE /api/todos/:id
<template each="c in post.comments">A comments table with a post_id foreign key, joined for you (batched — one query per page, not per row)
user_id column + auth configuredRows are scoped: WHERE user_id = :session.id
required maxlength="120" on an inputThe server re-validates the same rules before the write

Config — spark.json (optional)

{
  "db": "sqlite://./dev.db",
  "auth": { "table": "users", "identity": "email", "secret": "ENV.SESSION_SECRET" }
}

No spark.json at all? db defaults to sqlite://./dev.db, so bun spark-ssr runs on a folder holding one index.html. Postgres works with a postgres:// URL (Bun ships both drivers). A spark.json that exists but omits db stays deliberately database-free — the markdown-blog mode. Other keys: cors, fonts (spark-html-font head tags in every page), mail, maxBodyMb (413 at the socket), responseCache, api (API-only mode, below), rateLimit (below).

API-only mode

The same file you write for a page is a JSON backend — auto-CRUD (/api/todos), custom api/*.html endpoints, a generated OpenAPI spec and typed client all come from the template. API-only mode keeps that JSON surface and simply stops serving the HTML: a page GET returns its bound data as JSON instead of a rendered document. You declare the API in HTML as always — you just flip one signal when you don't want the page. Added in spark-ssr 1.3.0.

Turn it on — three ways, no config file required

WhereHowScope
Per page<spark-ssr table="todos" api />that page is API-only; no spark.json at all
CLIspark-ssr start --apiwhole app, for one run
Config"api": true in spark.jsonwhole-app default

Rendering is opt-in from there: "html": true (or "api": "hybrid") serves pages and the JSON API on the same server, and a per-page <spark-ssr render /> restores HTML for just one page (e.g. a single login screen in an otherwise headless backend). api and render are mirrors: one withholds a page's HTML, the other restores it.

{ "db": "sqlite://./app.db", "api": true,
  "cors": { "origin": "https://myapp.com" },
  "auth": { "table": "users", "identity": "email", "secret": "ENV.SESSION_SECRET" } }

Purely additive. api only toggles whether HTML is served — it never creates or removes a route. A project with no api signal renders and routes exactly as before. Hitting GET / in API mode (when no page owns it) returns a self-describing index — a branded page in a browser, or { powered_by, openapi, client, health } for a JSON client — and GET /api/health is generated for free.

Rate limits

Declarative and off by default (only the built-in login limiter runs). Enable it in one word or tune it per method, table, role, and route — no middleware to write. Added in 1.3.0.

Inline in the template — inferred like everything else

Because the API is declared in HTML, a route's limit rides the same block that declares it — one more attribute on a tag you already write:

<spark-ssr table="comments" api rate="10/1m" rate-key="ip" />

rate="<max>/<window>" (windows: s/m/h/d); optional rate-key is ip (default), session, ip+path, or header:X-Api-Key.

Cross-cutting policy — spark.json

"rateLimit": true applies a sane default (120/min/IP). An object tunes it, with tiers resolving narrowest-wins:

{ "rateLimit": {
    "window": "1m", "max": 120, "key": "ip",
    "methods": { "GET": { "max": 300 }, "POST": { "max": 30 }, "DELETE": { "max": 10 } },
    "tables":  { "uploads": { "methods": { "POST": { "max": 3 } } } },
    "roles":   { "anon": { "max": 60 }, "user": { "max": 600, "key": "session" }, "admin": false },
    "routes":  { "POST /api/login": { "max": 5, "window": "5m" } }
} }

Resolution order (each level inherits unset fields from the next, false = no limit): exact routes → inline rate=tables[t].methods[m]tables[t]roles[role]methods[m] → global default. Over the window → 429 with a Retry-After header and a JSON body naming the wait. The limiter is in-memory and per-process — behind multiple instances, limits apply per instance (a distributed limiter is a middleware.html hook, not core).

Routing & layouts

The filesystem is the router: pages/index.html/, pages/blog/[slug].html/blog/:slug — and :slug binds straight into queries. pages/_layout.html wraps every page in its folder (nested folders nest layouts); the layout is a component and <slot> is the page. A layout's <spark-ssr> vars are in scope for every page it wraps.

Stylesheets pair by filename: pages/about.css is linked automatically on /about, and a pages/_layout.css next to a layout is linked on every page that layout wraps — outermost layout first, the page's own css last, so a page rule wins ties with its layouts (spark-ssr ≥1.4.0).

A [param] page whose single-row lookup comes back empty 404s automatically — no code. Every error status gets a styled default page; drop pages/404.html (or any <status>.html) to override. Guards are declarative: <spark-ssr guard="session" redirect="/login" />, and guard="session.is_admin" works because role columns ride into the session.

Data sources — one block, four worlds

Named sources are page data with no endpoint exposed — what most pages actually want:

<spark-ssr>
  posts   = SELECT * FROM posts WHERE published = 1 ORDER BY created_at DESC
  author  = SELECT id, name, bio FROM users LIMIT 1
  repo    = https://api.github.com/repos/wilkinnovo/spark-html
  entries = ./content/posts/*.md
  weather = ./lib/weather.js
</spark-ssr>
  • SQL: params inject automatically: path params (:slug), query string (:q), body (:body.title), session (:session.id), headers, uploads (:file.url). All parameterized, always.
  • URL — a server-side fetch, JSON parsed, params interpolate.
  • Glob — files become rows: front-matter → columns, body → .body, filename → .slug. A blog with no database at all.
  • Moduleexport default (req, db) => value. Any ORM, any API, any computation — the escape hatch with a declarative front door.

List conventions ride along free: limit="20" gives ?page= (+ {posts.total}/{posts.pages}), ?sort= is validated against real columns, search="title,body" powers ?q=. <spark-pager for="posts"/> and <spark-search/> are the drop-in, no-JS controls.

Guide — build a Pinterest-style app (SQL-driven)

This is the shape of examples/pinterest — a real multi-table app (users, boards, pins, saves, follows) built entirely from templates. The whole walkthrough is four steps.

1 · Auth in one line of config

// spark.json
{ "db": "sqlite://./dev.db",
  "auth": { "table": "users", "identity": "email" } }

That alone gives you working /login, /signup, and /logout pages with zero UI written (override any of them by dropping your own pages/login.html). Signup enforces a unique identity and required password server-side; passwords hash on insert and never leave the table.

2 · The layout owns the chrome

<!-- pages/_layout.html -->
<link rel="stylesheet" href="/style.css">
<div import="/components/nav"></div>
<slot></slot>

{session} and {path} are ambient in server-rendered template expressions and server-only <script>s — the nav component branches logged-in/logged-out on session without declaring anything. A hydrating page's client component script doesn't get session; read the user's id from a declared data source (e.g. me.id) there instead.

3 · The home feed is a query and a loop

<!-- pages/index.html -->
<title>{q ? 'Search: ' + q : 'Pinspire'}</title>

<div class="masonry">
  <template each="pin in pins" key="pin.id">
    <a class="pin-card" href="/pin/{pin.id}">
      <img src="{pin.image}" alt="{pin.title}" loading="lazy">
      <p>{pin.title} — {pin.owner_name}</p>
    </a>
  </template>
</div>

<spark-ssr>
  pins = SELECT p.*, u.name AS owner_name, u.avatar AS owner_avatar
         FROM pins p JOIN users u ON u.id = p.user_id
         WHERE :q IS NULL OR p.title LIKE '%' || :q || '%'
         ORDER BY p.created_at DESC
</spark-ssr>

No script, no handlers — a pure function of (path, query), which makes this page a response-cache candidate in production: served at in-memory-string speed, invalidated automatically when a write touches its tables.

4 · A detail page: param, auto-404, no-JS writes

<!-- pages/pin/[id].html -->
<title>{pin.title} · Pinspire</title>
<img src="{pin.image}" alt="{pin.title}">

<form action="/api/saves" method="post" flash="Saved!">
  <input type="hidden" name="pin_id" value="{pin.id}">
  <button>Save</button>
</form>

<spark-ssr>
  pin = SELECT p.*, u.name AS owner_name FROM pins p
        JOIN users u ON u.id = p.user_id WHERE p.id = :id LIMIT 1
</spark-ssr>

A missing pin 404s by itself (single-row lookup, empty result). The save button is a plain form — it works with JavaScript disabled, redirects back, and flash="Saved!" survives the redirect as a one-shot toast. Uploads are the same story: a multipart form streams to uploads/ and :file.url binds the stored URL into the INSERT.

Then bun spark-ssr db shows the schema all of this implied, and bun spark-ssr already created it at startup. That's the app.

Guide — markdown & module data (no database at all)

spark-ssr doesn't assume SQL. Two shapes cover most database-free sites:

A markdown blog in one page

<!-- pages/index.html -->
<template each="post in posts">
  <a href="/blog/{post.slug}"><h2>{post.title}</h2><p>{post.excerpt}</p></a>
</template>

<spark-ssr>
  posts = ./content/posts/*.md
</spark-ssr>

<!-- pages/blog/[slug].html -->
<title>{post.title}</title>
<article>{post.body}</article>

<spark-ssr>
  post = ./content/posts/:slug.md
</spark-ssr>

Front-matter keys become columns, the body is .body, the filename is .slug, and a missing slug 404s automatically. Glob sources re-read only files whose mtime moved. Add markdown files, get pages — /sitemap.xml enumerates them for free.

Module sources — the TabTube shape

examples/tabtube is a YouTube search/watch app with zero database and zero custom endpoints — every source is a JS module:

<!-- pages/index.html (abridged) -->
<input bind:value="q" oninput={search} placeholder="Search">
<template each="video in results">
  <div class="card" onclick={play(video)}>{video.title}</div>
</template>

<script>
  async function search() { refresh(); }   // re-runs every source with ?q
</script>

<spark-ssr>
  results     = ./lib/search.js
  suggestions = ./lib/suggestion.js
</spark-ssr>
// lib/search.js — any code you want; req.query.q is the live search box
export default async (req) => {
  if (!req.query.q) return [];
  return await searchYouTube(req.query.q);   // your code, any API, any ORM
};

The page server-renders for real (a shareable /?q=… URL needs no JS), then hydrates; the client's re-search goes through the ambient refresh(), which re-runs the same modules with the current query string. Hydration is source-agnostic — SQL, markdown, URLs, and modules all hydrate and refresh identically.

Interactive pages — scripts, helpers, synthesized handlers

A page with handlers or binds that declares data hydrates: full HTML first, then the same spark-html runtime takes over. Its <script> becomes the client component, with four ambient helpers in scope — api_create(body), api_update(id, body), api_delete(id), and refresh(). Any handler the template references but you didn't write is synthesized, so an admin page is just its custom bits. <spark-ssr auto="none"> turns synthesis off.

One deliberate limit: on a hydrating page that script runs on the client only — the server renders from the data sources alone. Compute display fields (formatted dates, counts) in the data source, not the page script, or the first paint won't have them. A page with no handlers keeps the classic escape hatch: its plain <script> runs on the server.

Add live to any table block and every write through the server pings hydrated pages over SSE — every open tab refetches through its own session, scoping intact. No socket code.

A fifth ambient helper, navigate, is sugar over refresh() for the same-page-link shape — picking a row in a list, paging, switching a tab: <nav onclick={navigate}> intercepts same-origin, same-path <a href="?with=2"> clicks, swaps the URL with history.pushState, and refetches through refresh() instead of a full reload — with no JavaScript it's still a plain link, so nothing about the page changes for a no-JS visitor. It only fires for links that share the current page's path, so a link to a different route always does a real navigation. One boundary: it's page-scoped, the same as the other four helpers — a separately <div import>-ed component can't call it, only the page (or a layout, which shares page scope) can.

Auth & sessions

Built-in email/password sessions: POST /api/users?auth logs in, passwords hash on insert, sessions are HMAC-signed HttpOnly; SameSite=Lax cookies (Secure over HTTPS). Configuring auth gives you working /login, /signup, /logout pages with zero UI. Signup rejects a duplicate identity (409, friendly message) and enforces identity + password presence server-side — the built-in screens hold themselves to the same rules your forms get.

Tables with a user_id column are scoped automatically (WHERE user_id = :session.id); an is_admin/role column rides into the session so guard="session.is_admin" just works, and admins read scoped tables unscoped. OAuth or magic links? auth.plugin answers "who is this person?" — the framework still does sessions and cookies.

Forms without JavaScript

<form action="/api/posts" method="post" redirect="/admin" flash="Draft saved">
  <input name="title" required maxlength="120">
  <textarea name="body"></textarea>
  <button>Save draft</button>
</form>

A plain form post that succeeds 303s back (the app works with JavaScript disabled); a failed one re-renders the page with {errors.title} and {values.title} in scope. The form is the validator: required, maxlength, min/max, type="email", pattern — the constraints you wrote for the browser run again on the server before the write. Violations answer 422.

The template is the schema

bun spark-ssr db          # inferred schema vs live DB — a diff
bun spark-ssr db push     # apply additive changes
bun spark-ssr db push --force   # …and destructive ones (retype/drop)

serve runs the safe half automatically — missing tables are created and seed="./seed/x.json" files applied (once, idempotently) at startup, so a fresh clone runs on bun spark-ssr alone. Destructive changes never happen implicitly: a retype or column drop is reported by db diff and requires --force; on SQLite a forced retype rebuilds the table and copies rows across — it never silently drops data.

Background jobs & mail

<spark-ssr job="digest"  every="1d" />          <!-- jobs/digest.js on a schedule -->
<spark-ssr job="onOrder" on="insert:orders" />  <!-- after every matching write -->

A job is a module with the same shape as a data source — export default (req, db) => …, with the affected row on req.row for write-triggered jobs. Mail is one config line (a module or a webhook); mail() is ambient in every page/api/middleware script and on req.mail in jobs — with nothing configured it logs, so it always resolves.

Performance — fast by default

  • Precompiled render programs — every page/component parses once (mtime-invalidated) into a flat program; a request is a string-emitting loop. No DOM, no re-parse, no serializer per request. A 1,000-row page renders in ~6 ms.
  • Full-page response cache — in production, anonymous GETs of pure (path, query) pages serve straight from memory, auto-detected, invalidated by the same write hooks that power live. Requests carrying a session cookie always bypass it.
  • Streaming — production list pages flush the <head> immediately and stream rows as they render.
  • Batched relations — a 50-post page with comments is one WHERE post_id IN (…), not 50 queries.

The 1.0 release gate re-measured all of it: ~7,300–7,500 req/s on the 1,000-row benchmark page, above the baseline the suite defends. test/bench.js runs the whole battery — latency percentiles and RSS, not just throughput.

Security

spark-ssr ships auth, sessions, SQL, uploads, and file serving — it's treated as a security product with a framework attached. The audited posture lives in SECURITY.md, and every claim is pinned by a test that fails if the protection is removed: signed sessions with timing-safe verification, no open redirects or header injection through _redirect/?next, fully parameterized SQL with allowlisted ?sort columns, no path traversal, server-only trees never served, body-size caps (413), per-IP login rate limiting (429), unique-identity signup, and a production server that refuses to start with auth configured but no secret.

Deploy

bun spark-ssr build            # dist/ with a compiled single binary
bun spark-ssr build --docker   # …plus a Dockerfile: copy, run
bun spark-ssr start            # production: watch + live reload off, cache + streaming on

Spark 1.0 shipped with its release gate in public view: 100,000/100,000 fuzz scenarios clean, benchmarks above baseline, e2e green, and a security suite where every protection has a test that fails without it. The docs you just read describe the stable 1.x surface — see the API freeze document for what's stable vs experimental.