Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

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:

PackageWhat it isRuns in
@spillway-cms/clientA typed, dependency-free fetch client for the public content API (/api/public/*, search, entities by path), with preview and webhook helpersNode 18+, edge runtimes, browsers
@spillway-cms/astroAn Astro integration (spillway:client virtual module), <SpillwayPage> / <SpillwayStyles> components, getStaticPaths from the published paths, a signed revalidation endpointAstro 5+ (static, hybrid or SSR)
@spillway-cms/nextApp Router helpers: a tagged fetch, <SpillwayPage> as a server component, generateStaticParams, draft-mode previews, a route handler that revalidates by tag from the publish webhookNext.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 SpillwayError with status, code (the API's not_found, validation_error, unauthorized, ... or network_error with status 0), message, details and url.
  • client.with({ locale: 'es' }), client.with({ workstream, token }) and client.withPreview(state) derive clients; client.renderWithResponse() and client.request() return the Response and the parsed cacheTags too.
  • fetch in 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 Host picks the site, so point baseUrl at the site's own domain; a token that may open a site can name it with site: 'key' instead (sent as X-Spillway-Site).
  • @spillway-cms/client/webhook: readWebhook(request, secret) verifies the X-Spillway-Signature HMAC 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:

  1. 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 same workstream and token travel as ?workstream=<id>&token=<share token> (or in the spillway_preview cookie) and the client sends them as X-Spillway-Workstream and ?token=. The render endpoint then returns the branch version: pages the workstream edits or creates, with the branch's menus and settings.
  2. An API token with content.view_drafts plus ?preview=1 renders 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 calls revalidateTag for the tags the event maps to (and revalidatePath for the public path). Every request the tagged fetch makes carries spillway and a specific tag (spillway:path:/about, spillway:collection:stories, spillway:menu:main, spillway:site); by default the handler revalidates the global spillway tag 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' with onEvent returning 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, onEvent receives affected.paths and affected.tags to 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:client exports the configured client (/// <reference types="@spillway-cms/astro/client" /> in env.d.ts for types). It is server code; never import it from a client script.
  • <SpillwayPage> fetches and renders; pass page instead of path when you fetched in the frontmatter (to set a 404 status or read page.seo in your layout). <SpillwayStyles themeCss={page.site.themeCss} /> inlines the theme variables and the block stylesheet; renderHead(page) and renderMenu(items) from @spillway-cms/astro/render give you the <head> and the navigation markup.
  • Forms: the Form block posts to /api/forms/<key>/submit; renderPage makes that absolute to the Spillway origin (the public submit accepts cross-origin posts), or set formsOrigin: '' behind a proxy.
  • Previews need an on-demand route (export const prerender = false, so an adapter): see examples/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 });
  • createSpillwayClient wires the tagged fetch (spillwayFetch); tagsForUrl shows the mapping. Do not list @puckeditor/core or the blocks in serverExternalPackages: Next must bundle them so they use its own React.
  • <SpillwayPage> is an async server component; it reads draftMode() and the preview cookie itself (preview="auto"), or takes an explicit state. <SpillwayBlocks page> renders a page you already fetched; <SpillwayStyles>, <SpillwayJsonLd> and metadataFor cover the head.
  • spillwayRewrites(baseUrl) in next.config proxies /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/exit turns it off.

Rendering on Spillway or on your front end?

Spillway rendersAstro / Next.js adapter
SetupOne process, nothing elseA second deployment plus a webhook rule
BlocksThe block library, modules' blocks, themes from the registryThe same block library; add your own React blocks around it
ShellThe site shell, menus, alerts bar, consent, analytics beaconYours
Personalization and experimentsIslands, per-visitorDefault variant only
PreviewsBuilt in (/_preview, workstream previews, share links)Through the adapter's preview route
CachingTagged page cache, Redis, Cache-Tag for CDNsNext data cache / static build, revalidated by webhook, plus the same Cache-Tag upstream
Search page, sitemap, robots, llms.txtBuilt inBuild from client.search() and client.paths()
Good forMost marketing sitesSites 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

Was this page helpful?