spark-ssr 1.3.0 makes the thing spark-ssr was quietly already good at official: running as a JSON API backend. The same single-file templates you write for pages now serve as an API — you flip one signal to drop the HTML — plus declarative rate limits, all declared in the HTML with no new concepts to learn.
API-only mode: declare in HTML, don't serve the HTML
spark-ssr already infers a REST surface from your templates: a
<spark-ssr table="todos"> block gives you
GET/POST/PUT/PATCH/DELETE /api/todos, custom
api/*.html endpoints return JSON, and an OpenAPI spec plus a
typed client are generated for free. API-only mode keeps
all of that and stops rendering the pages — a page GET returns
its bound data as JSON instead of a document.
Turn it on wherever is smallest for the job — no
spark.json required:
<!-- per page: this route is API-only, nothing else to configure -->
<spark-ssr table="todos" api />
…or app-wide with spark-ssr start --api, or
"api": true in spark.json. Rendering is opt-in
from there: "html": true (or "api": "hybrid")
serves pages and the JSON API on one server, and a per-page
<spark-ssr render /> restores HTML for just one page —
handy for a single login screen in an otherwise headless backend.
api and render are mirrors: one withholds a
page's HTML, the other restores it.
It's purely additive: api only toggles
whether HTML is served — it never creates or removes a route, so a project
with no api signal renders and routes exactly as before.
Hitting GET / in API mode returns a self-describing index
(a branded page in a browser, JSON for a client), and
GET /api/health comes generated.
Rate limits, declared in the template
Because the API is declared in HTML, a route's rate limit rides the same block — one more attribute:
<spark-ssr table="comments" api rate="10/1m" rate-key="ip" />
For cross-cutting policy, spark.json takes a
rateLimit object with tiers that resolve narrowest-wins — per
route, per table, per role, per method:
{ "rateLimit": {
"window": "1m", "max": 120, "key": "ip",
"methods": { "GET": { "max": 300 }, "POST": { "max": 30 } },
"roles": { "anon": { "max": 60 }, "user": { "max": 600, "key": "session" }, "admin": false },
"routes": { "POST /api/login": { "max": 5, "window": "5m" } }
} }
Over the window a request gets a 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, by design: no new dependency domain).
A whole blog + comments, in a handful of files
Three lines declare the data model and a background email job — all
API-only, all served as JSON, zero spark.json:
<!-- pages/_schema.html -->
<spark-ssr table="posts" api seed="./seed/posts.json" />
<spark-ssr table="comments" api />
<spark-ssr job="notify-author" on="insert:comments" api />
Add a page with two named queries and you've got a post plus its comment
thread as one JSON document; add guard="session" and
rate="10/1m" to the comments block and writes are auth-gated
and throttled. The whole backend is a few short HTML files — the data
model, REST API, auth, validation, rate limits, and transactional email,
each declared next to the thing it describes.
Why this fits Spark
No new concepts, no build step, no new package — API-only is a fourth relocation of the same server you already write. It spends zero of the spark-html core's gzip budget (it's all server-side spark-ssr code), and every rate-limit tier is pinned by a test that fails if the protection is removed. The pitch stays intact: declare it in HTML, infer the rest, flip one attribute to drop the pages.
Full details in the SSR guide · spark-ssr on npm.