Next.js headless CMS
Use Spunto CMS as the headless CMS of a Next.js site — typed client from the public OpenAPI spec, server components, block rendering, images and webhook revalidation.
This guide builds the usual first project: a blog on Next.js (App Router) whose posts are written in a Spunto workspace. Editors work in lists and cards; the site reads the same rows over REST and refreshes itself when a card changes.
There is no export and no second store in between — the card an editor
edits is the record the site reads. Everything below runs against
https://cms.spunto.net on a free account.
1. Model the content
In a workspace, create a list called Blog posts with a key prefix such as
BLOG. Then, in the workspace settings:
- under Statuses (
/w/{workspaceId}/settings/statuts), add a status namedPublished. Statuses belong to the workspace, so every list shares them; a post is live when it has this one; - under Properties (
/w/{workspaceId}/settings/proprietes), add atextproperty namedSlug. Its storage key is derived from the name — check that it readsslug.
Write a post or two: a title, a slug, a body, and the Published status for
the ones that should appear.
Shortcut
On a new workspace, the overview offers Create the example blog: it does
this whole step for you — the list, the Published status, the slug property,
plus a Published on date and Tags — with three posts, two of them published.
It needs the admin role, and it is only shown while the workspace has no list.
A status, or real publishing
This guide marks a post live with a status: it is on every card, it shows as a
column on a board, and an editor moves a post by dragging it — but the site then
reads the post as it is being edited. Cards also have a built-in
draft and published version: add
"version": "published" to the queries below, drop the status filter, and the
site keeps serving the last published text while the next draft is written.
Two identifiers come from the address bar of the list,
/w/{workspaceId}/c/{collectionId}. Then mint an API key with the viewer
role under Clés API (/w/{workspaceId}/settings/api, details).
A site only reads; a key that can write is a key you have to protect harder.
SPUNTO_TOKEN=spk_...
SPUNTO_WORKSPACE_ID=...
SPUNTO_COLLECTION_ID=...2. A typed client in two commands
The API is described by an OpenAPI specification served at
/openapi.json. Generate types from it, and use
openapi-fetch to call it:
npm install openapi-fetch server-only
npx openapi-typescript https://cms.spunto.net/openapi.json -o lib/spunto-api.d.tsRe-run the second command whenever you want the types of a newer API. Fields are only ever added to responses as optional, so an old generated file keeps working against a newer server.
import "server-only"
import createClient from "openapi-fetch"
import type { components, paths } from "./spunto-api"
export type Item = components["schemas"]["Item"]
export type ItemDetail = components["schemas"]["ItemDetail"]
export type Block = components["schemas"]["Block"]
export type MentionedItem = components["schemas"]["MentionedItem"]
const workspaceId = process.env.SPUNTO_WORKSPACE_ID!
const collectionId = process.env.SPUNTO_COLLECTION_ID!
export const cms = createClient<paths>({
baseUrl: "https://cms.spunto.net/api",
headers: { Authorization: `Bearer ${process.env.SPUNTO_TOKEN}` },
})
// One call returns the list, its properties and the workspace's statuses:
// resolve names to identifiers here rather than copying ids into env vars.
async function schema() {
const { data, error } = await cms.GET(
"/workspaces/{workspaceId}/collections/{collectionId}",
{ params: { path: { workspaceId, collectionId } } },
)
if (error) throw new Error(`Spunto: ${error.error}`)
const published = data.statuses.find((s) => s.label === "Published")
const slug = data.fields.find((f) => f.key === "slug")
if (!published || !slug) throw new Error("Missing `Published` status or `slug` property")
return { publishedId: published.id, slugFieldId: slug.id }
}
type ViewDefinition = components["schemas"]["ViewDefinition"]
async function query(definition: ViewDefinition, cursor: string | null, limit: number) {
const { data, error } = await cms.POST(
"/workspaces/{workspaceId}/collections/{collectionId}/query",
{ params: { path: { workspaceId, collectionId } }, body: { definition, cursor, limit } },
)
if (error) throw new Error(`Spunto: ${error.error}`)
return data
}
export async function listPosts(): Promise<Item[]> {
const { publishedId } = await schema()
const posts: Item[] = []
let cursor: string | null = null
do {
const page = await query(
{
filter: { op: "eq", field: { fieldId: "sys:status" }, value: publishedId },
sort: [{ field: { fieldId: "sys:created_at" }, direction: "desc" }],
includeSecondaryLists: false,
},
cursor,
200,
)
posts.push(...page.rows)
cursor = page.nextCursor
} while (cursor)
return posts
}
export async function getPost(slug: string): Promise<ItemDetail | null> {
const { publishedId, slugFieldId } = await schema()
const page = await query(
{
filter: {
and: [
{ op: "eq", field: { fieldId: slugFieldId }, value: slug },
{ op: "eq", field: { fieldId: "sys:status" }, value: publishedId },
],
},
sort: [],
includeSecondaryLists: false,
},
null,
1,
)
const row = page.rows[0]
if (!row) return null
// A page of results never carries bodies; one card does.
const { data, error } = await cms.GET("/workspaces/{workspaceId}/items/{itemId}", {
params: { path: { workspaceId, itemId: row.id } },
})
if (error) throw new Error(`Spunto: ${error.error}`)
return data
}
export function slugOf(item: Item): string {
return String(item.properties.slug ?? item.id)
}Three things the API does that this code relies on:
- a filter is a tree, sent in a
POSTbody — that is why reading usesPOST …/query(querying); - pages are keyset cursors: loop until
nextCursorisnull. There is no page number to compute; - property values are keyed by
keyinitem.properties, while a filter names a property by itsfieldId. Mixing the two is the classic 400 (limits).
server-only is there for the token: this module must never end up in a client
bundle.
3. The pages
import Link from "next/link"
import { listPosts, slugOf } from "@/lib/spunto"
export const revalidate = 3600
export default async function Blog() {
const posts = await listPosts()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`/blog/${slugOf(post)}`}>{post.title}</Link>
</li>
))}
</ul>
)
}import { notFound } from "next/navigation"
import { Blocks } from "@/components/blocks"
import { getPost, listPosts, slugOf } from "@/lib/spunto"
export const revalidate = 3600
// Prerender what is published at build time; a post published later renders
// on its first request, then is cached like the others.
export async function generateStaticParams() {
return (await listPosts()).map((post) => ({ slug: slugOf(post) }))
}
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<Blocks blocks={post.blocks.blocks} mentions={post.mentions} />
</article>
)
}Both pages are static with an hourly fallback; step 6 makes them update as soon as an editor changes something.
4. Render the body
A body is a list of typed blocks, and a block's text is a list of spans — a
string plus marks (page bodies). The format cannot
carry HTML, so there is nothing to sanitise and no
dangerouslySetInnerHTML anywhere:
import { createContext, useContext, type ReactNode } from "react"
import type { Block, MentionedItem } from "@/lib/spunto"
type Span = NonNullable<Block["text"]>[number]
const Mentions = createContext<Map<string, MentionedItem>>(new Map())
function Text({ spans = [] }: { spans?: Span[] }) {
const mentions = useContext(Mentions)
return spans.map((span, i) => {
// A mention: a card id. Current key and title are in `post.mentions`;
// missing from it (deleted, unpublished) → a neutral rendering, never the title.
if (span.mention) {
const card = mentions.get(span.mention)
return <span key={i} className="mention">{card ? `${card.key} ${card.title}` : "unavailable card"}</span>
}
let node: ReactNode = span.text
if (span.code) node = <code>{node}</code>
if (span.bold) node = <strong>{node}</strong>
if (span.italic) node = <em>{node}</em>
if (span.strike) node = <s>{node}</s>
if (span.link) node = <a href={span.link}>{node}</a>
return <span key={i}>{node}</span>
})
}
function One({ block, inner }: { block: Block; inner?: ReactNode }) {
switch (block.type) {
case "heading": {
const H = (["h2", "h3", "h4"] as const)[(block.level ?? 1) - 1]
return <H><Text spans={block.text} /></H>
}
case "quote":
return <blockquote><Text spans={block.text} /></blockquote>
case "callout":
return <aside><Text spans={block.text} /></aside>
case "todo":
return <p><input type="checkbox" checked={!!block.checked} readOnly /> <Text spans={block.text} /></p>
case "code":
return <pre><code data-language={block.language}>{block.code}</code></pre>
case "divider":
return <hr />
case "image":
return <img src={`/cms-files/${block.fileId}`} alt={block.alt ?? ""} />
case "embed":
// `embedUrl` is rebuilt by the API from an allowlist: never use `url` here.
return <iframe src={block.embedUrl} title={block.provider} loading="lazy"
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation" />
case "bookmark":
return <a className="bookmark" href={block.url}>{block.title ?? new URL(block.url!).hostname}</a>
case "toggle":
// A toggle: its content is what follows it with a greater `indent`.
return <details><summary><Text spans={block.text} /></summary>{inner}</details>
default:
// A type this renderer doesn't know yet: its text if it has any, nothing otherwise.
// `type` is an open string — a new block type won't break this site.
return block.text ? <p><Text spans={block.text} /></p> : null
}
}
function render(blocks: Block[]): ReactNode[] {
const out: ReactNode[] = []
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
if (block.type === "toggle") {
let end = i + 1
while (end < blocks.length && blocks[end].indent > block.indent) end++
out.push(<One key={block.id} block={block} inner={render(blocks.slice(i + 1, end))} />)
i = end - 1
} else if (block.type === "bulleted" || block.type === "numbered") {
// Consecutive `bulleted` / `numbered` blocks form one list.
const items: Block[] = []
while (blocks[i]?.type === block.type) items.push(blocks[i++])
i--
const List = block.type === "bulleted" ? "ul" : "ol"
out.push(
<List key={block.id}>
{items.map((item) => <li key={item.id}><Text spans={item.text} /></li>)}
</List>,
)
} else {
out.push(<One key={block.id} block={block} />)
}
}
return out
}
export function Blocks({ blocks, mentions = [] }: { blocks: Block[]; mentions?: MentionedItem[] }) {
return (
<Mentions.Provider value={new Map(mentions.map((m) => [m.id, m]))}>
{render(blocks)}
</Mentions.Provider>
)
}Headings start at h2 because the post title is the page's h1. Every block
also has an indent (0 to 5): this renderer uses it for a toggle's content and
ignores it elsewhere.
Three things to keep when you adapt it:
- Always keep a
defaultcase.Block.typeis an open string in responses: the API adds block types over time, and a site built today must render tomorrow's body — degraded, not broken. - An embed's iframe takes
embedUrl, neverurl. The API recomputes it on every write from an allowlist (YouTube, Vimeo, Loom, Figma, Google Docs, Sheets and Slides, CodeSandbox, CodePen);urlis the page a reader would open. - A mention is resolved by the API, not by you.
post.mentionscarries the current key and title of every card the body cites. A card that was deleted — or, when you read with?version=published, that is not published — is not in it: render something neutral, never the span'stext, which is the title the card had when it was cited. Linking a mention to your own page is a lookup on your side (itsidagainst your list of posts).
5. Serve the images
An image block carries a fileId, never a URL. Attachments have no public
address in Spunto: the API serves the bytes itself, behind the same bearer
token as everything else (files). So the site relays them
through a route of its own, which keeps the token on the server:
import { cms } from "@/lib/spunto"
export async function GET(_: Request, { params }: { params: Promise<{ fileId: string }> }) {
const { fileId } = await params
const { response } = await cms.GET("/workspaces/{workspaceId}/files/{fileId}/content", {
params: { path: { workspaceId: process.env.SPUNTO_WORKSPACE_ID!, fileId } },
parseAs: "stream",
})
if (!response.ok) return new Response(null, { status: 404 })
return new Response(response.body, {
headers: {
"content-type": response.headers.get("content-type") ?? "application/octet-stream",
"cache-control": "public, max-age=86400",
},
})
}A file id that is not in your workspace answers 404, like anything else that is not yours.
The type served is not the type uploaded
Images and PDFs come back with their type. Anything outside that list — an SVG
included — is served as application/octet-stream, on purpose: an SVG is a
scriptable document.
6. Update the site when content changes
Spunto writes every change to an event log and can push it to a webhook. Point one at the site and revalidate:
import { createHmac, timingSafeEqual } from "node:crypto"
import { revalidatePath } from "next/cache"
export async function POST(request: Request) {
const raw = await request.text()
const header = request.headers.get("x-spunto-signature") ?? ""
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
const expected = createHmac("sha256", process.env.SPUNTO_WEBHOOK_SECRET!)
.update(`${parts.t}.${raw}`)
.digest()
const given = Buffer.from(parts.v1 ?? "", "hex")
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
if (!fresh || given.length !== expected.length || !timingSafeEqual(given, expected)) {
return new Response("invalid signature", { status: 401 })
}
revalidatePath("/blog", "layout")
return new Response(null, { status: 204 })
}Then, under Webhooks (/w/{workspaceId}/settings/webhooks, for an admin
of the workspace),
add an endpoint with the URL https://your-site.example/api/revalidate and the
event pattern item.*. Its signing secret is shown once: put it in
SPUNTO_WEBHOOK_SECRET.
- The timestamp is inside the signature (
t.body), so a captured delivery cannot be replayed later — that is what the five-minute window checks. - A failed delivery is retried five times over about an hour and a quarter, and nothing is lost if your site is down: the log is still readable from a cursor.
- An import or a bulk edit sends one delivery for the batch, not one per card, so publishing 300 posts at once revalidates once.
revalidatePath("/blog", "layout") throws away every page under /blog. That is
the right default for a blog, and the simplest thing that is never stale. A
larger site can read event.resource.id from the payload and revalidate only
the page that changed.
Already in Notion?
What changes when a site stops reading from the Notion API, point by point, is in Notion as a headless CMS.
If your posts live in a Notion database today, POST /workspaces/{workspaceId}/import
is the route built for moving them. The request names its source (notion)
and each card carries its Notion page id as externalId, so the import is replayable: running it twice updates instead of duplicating, and a
card that has not changed is not rewritten at all. You map Notion's properties
and blocks to Spunto's yourself; the rules are in
imports you can run twice.
What this guide does not cover
- Previews of unpublished posts. Filter on another status with a second, short-lived key, or render drafts only behind your own auth.
- Search over post bodies. The API does not index bodies; workspace search covers titles only. Build your own index at build time if you need one.
- Rate limits. There are none today, which is not a promise. Static pages plus webhook revalidation keep the request count low anyway.
Every route used here is in the API reference, generated from the same specification your types came from.