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 says | The 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 configured | Rows are scoped: WHERE user_id = :session.id |
required maxlength="120" on an input | The 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
| Where | How | Scope |
|---|---|---|
| Per page | <spark-ssr table="todos" api /> | that page is API-only; no spark.json at all |
| CLI | spark-ssr start --api | whole app, for one run |
| Config | "api": true in spark.json | whole-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. - Module —
export 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 powerlive. 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.