Front-end adapters: Astro and Next.js
Spillway renders its own pages: one process serves the API, the admin and the public site, with a tagged page cache in front. That is the simplest setup and the one most sites should start with. The adapters are for sites that render Spillway content elsewhere: a marketing site that already lives in Astro or Next.js, a front end shared with other data sources, a static build on a CDN edge, or a design system that is not the block library.
Three MIT packages make that work:
| Package | What it is | Runs in |
|---|---|---|
@spillway-cms/client | A typed, dependency-free fetch client for the public content API (/api/public/*, search, entities by path), with preview and webhook helpers | Node 18+, edge runtimes, browsers |
@spillway-cms/astro | An Astro integration (spillway:client virtual module), <SpillwayPage> / <SpillwayStyles> components, getStaticPaths from the published paths, a signed revalidation endpoint | Astro 5+ (static, hybrid or SSR) |
@spillway-cms/next | App Router helpers: a tagged fetch, <SpillwayPage> as a server component, generateStaticParams, draft-mode previews, a route handler that revalidates by tag from the publish webhook | Next.js 15+ |
Both adapters render the same blocks as Spillway itself: the server-safe render functions in @spillway-cms/blocks, through Puck's RSC renderer (@puckeditor/core/rsc). Editors keep the page builder, the pre-publish checks, workstreams and previews; the front end decides the shell, the routing and the hosting. Examples live in examples/astro-site and examples/next-site.
How it works
The adapters do not re-implement the renderer. They read one endpoint, GET /api/public/render/<path>, which returns what Spillway would have rendered as HTML, as data:
{
"entity": { "...": "the published snapshot (title, data, locale, path, ...)" },
"type": { "key": "page", "label": "Page", "titleField": "title", "hasPath": true, "fields": ["..."] },
"path": "/es/about",
"locale": "es",
"data": { "content": [ { "type": "Collection", "props": { "id": "…", "items": ["resolved"] } } ], "root": { "props": {} } },
"richHtml": { "body": "<p>rendered rich text for article-style types</p>" },
"media": { "<id>": { "url": "https://cms.example.org/media/…", "alt": "…" } },
"seo": { "title": "About | Site", "description": "…", "canonical": "…", "alternates": [], "jsonLd": [], "noindex": false },
"menus": { "main": [ { "label": "About", "href": "/about", "current": true } ], "footer": [] },
"cacheTags": [ "entity:…", "type:page", "site:default", "collection:…", "list:post", "menu:main", "menu:footer" ],
"site": { "name": "…", "origin": "https://cms.example.org", "locales": ["en", "es"], "theme": {}, "themeCss": ":root{…}" },
"preview": false
}
data is the Puck document after the server-side resolvers ran: Collection blocks carry their items, Form blocks their definition, Image and Hero blocks their media with dimensions and a focal point, Shared blocks their content, Embed blocks the site's allowed hosts. Author HTML is sanitized. Media URLs are absolute (the Spillway origin), internal links stay relative so they resolve on the front end. The response carries the same Cache-Tag header as the HTML page and is cached per path in Spillway with those tags, so a publish evicts it the moment it evicts the HTML.
The other public endpoints (/api/public/site, /menus/:key, /paths, /entities/:type/:idOrSlug, /collections/:key/items, /media/:id) are listed in api.md. All are GET, need no session for published content, and carry Cache-Tag.
What does not cross the boundary: Personalize and Experiment blocks render their default variant (the islands that swap content are served by Spillway's own pages); the analytics beacon and the alerts bar are Spillway-page features; the search page and the sitemap are yours to build from client.search() and client.paths().
The client
import { createClient } from '@spillway-cms/client';
const spillway = createClient({
baseUrl: 'https://cms.example.org',
token: process.env.SPILLWAY_TOKEN, // optional: previews, multisite; server only
locale: 'en', // optional default
});
const page = await spillway.render('/about'); // PublicRender (resolved blocks, SEO, menus, tags)
const raw = await spillway.page('/about'); // Entity, GET /api/entities/by-path
const post = await spillway.entity('post', 'hello'); // by id or slug
const list = await spillway.collection('stories', { filters: [{ field: 'category', op: 'eq', value: 'news' }], page: 2 });
const menu = await spillway.menu('main', { path: '/about' });
const site = await spillway.site();
const hits = await spillway.search('kayak', { type: 'post' });
const image = await spillway.media(id);
const paths = await spillway.paths({ type: 'page' });
- Errors are
SpillwayErrorwithstatus,code(the API'snot_found,validation_error,unauthorized, ... ornetwork_errorwith status 0),message,detailsandurl. client.with({ locale: 'es' }),client.with({ workstream, token })andclient.withPreview(state)derive clients;client.renderWithResponse()andclient.request()return theResponseand the parsedcacheTagstoo.fetchin the options swaps the transport: Next's tagged fetch, a retrying wrapper, or an in-process Hono app in tests (inst.app.request).- Multisite: the request's
Hostpicks the site, so pointbaseUrlat the site's own domain; a token that may open a site can name it withsite: 'key'instead (sent asX-Spillway-Site). @spillway-cms/client/webhook:readWebhook(request, secret)verifies theX-Spillway-SignatureHMAC over the raw body with Web Crypto,affectedByWebhook(payload)lists the paths and tags an event touches.
Previews
Spillway has two preview credentials and the adapters accept both:
- Workstream share link. An editor creates one in the admin (
POST /api/workstreams/:id/share-link); it is/_preview/ws/<id>/?token=…on the Spillway site. On a headless site the sameworkstreamandtokentravel as?workstream=<id>&token=<share token>(or in thespillway_previewcookie) and the client sends them asX-Spillway-Workstreamand?token=. The render endpoint then returns the branch version: pages the workstream edits or creates, with the branch's menus and settings. - An API token with
content.view_draftsplus?preview=1renders the latest revision of any status. This is the front end's own server-side token; guard the route that enables it with a secret.
previewFromRequest(req) reads either form from a Request (query string, then cookie), from Next's cookies() store or from { draft: true }; previewCookieValue(state) serializes it for the cookie. Preview responses are Cache-Control: no-store, X-Spillway-Cache: BYPASS, noindex, and never land in Spillway's cache.
Revalidation
Spillway announces content changes through notification rules (Site settings → Notifications): a rule with channel webhook on entity.published, entity.unpublished or entity.deleted posts JSON to your URL from the outbox (three attempts, SSRF-guarded), signed with X-Spillway-Signature: sha256=<HMAC-SHA256 of the body> when the rule has a secret. The payload's data.entity carries publicPath (/es/about) and cacheTags (entity:<id>, type:<key>, site:<id>, list:<type>), so a receiver can purge exactly what the server would. Unpublishing covers the scheduler's unpublishAt too; deleting a draft announces nothing. A test send from the admin arrives with data.test: true and the handlers acknowledge it without revalidating.
- Next.js:
createRevalidateHandler({ secret })verifies the signature and callsrevalidateTagfor the tags the event maps to (andrevalidatePathfor the public path). Every request the tagged fetch makes carriesspillwayand a specific tag (spillway:path:/about,spillway:collection:stories,spillway:menu:main,spillway:site); by default the handler revalidates the globalspillwaytag as well, so lists, menus and pages that embed the changed entity refresh on their next request. That is the same trade Spillway's own cache makes (a publish evicts every page) and the right default for most sites;scope: 'entity'withonEventreturning your own tags is the narrow version. - Astro (static):
createRevalidateEndpoint({ secret, onEvent: deployHook(url) })verifies the event and triggers a rebuild through your host's deploy hook. On an SSR or hybrid site,onEventreceivesaffected.pathsandaffected.tagsto purge a CDN or a cache of your own.
Caching
Spillway caches the render JSON per site and path with the page's tags (X-Spillway-Cache: HIT | MISS) and sends Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300 plus Cache-Tag, so a CDN between the front end and Spillway can cache and purge by tag like it would the HTML. Query-dependent pages (exposed filters, calendars) are not cached as JSON; previews never are.
On the front end: Next's data cache holds every tagged fetch until a webhook or revalidate seconds; Astro's static output is the cache and a rebuild refreshes it. For SSR Astro, cache on the cacheTags the response carries.
Setup: Astro
pnpm add @spillway-cms/astro @spillway-cms/client @spillway-cms/blocks @puckeditor/core react react-dom
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { spillway } from '@spillway-cms/astro';
export default defineConfig({ integrations: [spillway({ baseUrl: process.env.SPILLWAY_URL, token: process.env.SPILLWAY_TOKEN })] });
---
// src/pages/[...path].astro
import { client } from 'spillway:client';
import { spillwayStaticPaths, pathFromParams } from '@spillway-cms/astro';
import SpillwayPage from '@spillway-cms/astro/components/SpillwayPage.astro';
export const getStaticPaths = () => spillwayStaticPaths(client);
const path = Astro.props.path ?? pathFromParams(Astro.params);
---
<SpillwayPage path={path} />
spillway:clientexports the configured client (/// <reference types="@spillway-cms/astro/client" />inenv.d.tsfor types). It is server code; never import it from a client script.<SpillwayPage>fetches and renders; passpageinstead ofpathwhen you fetched in the frontmatter (to set a 404 status or readpage.seoin your layout).<SpillwayStyles themeCss={page.site.themeCss} />inlines the theme variables and the block stylesheet;renderHead(page)andrenderMenu(items)from@spillway-cms/astro/rendergive you the<head>and the navigation markup.- Forms: the Form block posts to
/api/forms/<key>/submit;renderPagemakes that absolute to the Spillway origin (the public submit accepts cross-origin posts), or setformsOrigin: ''behind a proxy. - Previews need an on-demand route (
export const prerender = false, so an adapter): seeexamples/astro-site/src/pages/preview.astro.
Setup: Next.js
pnpm add @spillway-cms/next @spillway-cms/client @spillway-cms/blocks @puckeditor/core
// lib/spillway.ts
import { createSpillwayClient } from '@spillway-cms/next';
export const spillway = createSpillwayClient({ baseUrl: process.env.SPILLWAY_URL!, token: process.env.SPILLWAY_TOKEN });
// app/[[...slug]]/page.tsx
import { pathFromParams, spillwayStaticParams } from '@spillway-cms/next';
import { SpillwayPage, fetchPage, metadataFor } from '@spillway-cms/next/page';
import { spillway } from '../../lib/spillway';
export const generateStaticParams = () => spillwayStaticParams(spillway);
export async function generateMetadata({ params }) { return metadataFor(await fetchPage(spillway, pathFromParams(await params))); }
export default async function Page({ params }) { return <SpillwayPage client={spillway} path={pathFromParams(await params)} />; }
// app/api/revalidate/route.ts
import { createRevalidateHandler } from '@spillway-cms/next/server';
export const POST = createRevalidateHandler({ secret: process.env.SPILLWAY_WEBHOOK_SECRET! });
// app/api/preview/route.ts
import { createPreviewHandler } from '@spillway-cms/next/server';
export const GET = createPreviewHandler({ client: spillway, secret: process.env.PREVIEW_SECRET });
createSpillwayClientwires the tagged fetch (spillwayFetch);tagsForUrlshows the mapping. Do not list@puckeditor/coreor the blocks inserverExternalPackages: Next must bundle them so they use its own React.<SpillwayPage>is an async server component; it readsdraftMode()and the preview cookie itself (preview="auto"), or takes an explicit state.<SpillwayBlocks page>renders a page you already fetched;<SpillwayStyles>,<SpillwayJsonLd>andmetadataForcover the head.spillwayRewrites(baseUrl)innext.configproxies/api/forms/*and/media/*to Spillway so the blocks' relative URLs work from your origin.- Previews:
/api/preview?path=/about&workstream=<id>&token=<share token>(a share link) or?path=&preview=1&secret=<PREVIEW_SECRET>(drafts through your token) turn on draft mode and set the cookie;/api/preview/exitturns it off.
Rendering on Spillway or on your front end?
| Spillway renders | Astro / Next.js adapter | |
|---|---|---|
| Setup | One process, nothing else | A second deployment plus a webhook rule |
| Blocks | The block library, modules' blocks, themes from the registry | The same block library; add your own React blocks around it |
| Shell | The site shell, menus, alerts bar, consent, analytics beacon | Yours |
| Personalization and experiments | Islands, per-visitor | Default variant only |
| Previews | Built in (/_preview, workstream previews, share links) | Through the adapter's preview route |
| Caching | Tagged page cache, Redis, Cache-Tag for CDNs | Next data cache / static build, revalidated by webhook, plus the same Cache-Tag upstream |
| Search page, sitemap, robots, llms.txt | Built in | Build from client.search() and client.paths() |
| Good for | Most marketing sites | Sites that already have a front end, multi-source sites, edge-static hosting |
Both can run at once: Spillway keeps serving its own pages (and the admin, previews, forms, media) while the adapter serves the same content elsewhere.
Spillway 0.1.0 · Edit this page on GitHub