Astro headless CMS
Use Spunto CMS as the headless CMS of an Astro site — a content collection loaded over REST, typed from the public OpenAPI spec, block rendering in .astro components, images copied at build time, and a rebuild on every publication.
This guide builds a static blog with Astro whose posts are written in a Spunto workspace. Editors work in lists and cards; at build time, an Astro content collection reads the published cards over REST, and the site is rebuilt each time someone publishes.
There is no export and no second store in between — the card an editor
publishes is the record the site reads. Everything below runs against
https://cms.spunto.net on a free account, and was run end to end on Astro 7.
1. Model the content
The Templates gallery (/w/{workspaceId}/templates, for an admin of the
workspace — a new workspace shows it on its overview) has a Blog template:
Use makes a list Blog posts (prefix BLOG) with a slug property, a
Published on date and Tags, and three example posts. To do it by hand
instead: a list, then a text property named Slug under Properties
(/w/{workspaceId}/settings/proprietes) — check that its key reads slug.
Then publish the posts that should appear: the Publish button in a card's header. Every card is a draft until it is published, and this site only ever reads the published version. An editor can keep rewriting a published post; the site keeps the last published text until the card is published again.
Two identifiers come from the address bar of the list,
/w/{workspaceId}/c/{collectionId}. Then mint an API key with the viewer
role under API keys (/w/{workspaceId}/settings/api, details).
A build only reads; a key that can write is a key you have to protect harder.
SPUNTO_TOKEN=spk_...
SPUNTO_WORKSPACE_ID=...
SPUNTO_COLLECTION_ID=...Astro reads .env through import.meta.env, and a variable without the
PUBLIC_ prefix is never sent to the browser. In a static build the token is
only used on your build machine.
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
npx openapi-typescript https://cms.spunto.net/openapi.json -o src/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 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"]
type ViewDefinition = components["schemas"]["ViewDefinition"]
const workspaceId = import.meta.env.SPUNTO_WORKSPACE_ID
const collectionId = import.meta.env.SPUNTO_COLLECTION_ID
const cms = createClient<paths>({
baseUrl: "https://cms.spunto.net/api",
headers: { Authorization: `Bearer ${import.meta.env.SPUNTO_TOKEN}` },
})
const definition: ViewDefinition = {
sort: [{ field: { fieldId: "sys:created_at" }, direction: "desc" }],
includeSecondaryLists: false,
}
// `version: "published"`: only published cards, as they were when published.
// What an editor is still typing never reaches the build.
async function page(cursor: string | null) {
const { data, error } = await cms.POST(
"/workspaces/{workspaceId}/collections/{collectionId}/query",
{
params: { path: { workspaceId, collectionId } },
body: { definition, version: "published", cursor, limit: 200 },
},
)
if (error) throw new Error(`Spunto: ${error.error}`)
return data
}
export async function listPublished(): Promise<Item[]> {
const rows: Item[] = []
let cursor: string | null = null
do {
const { rows: more, nextCursor } = await page(cursor)
rows.push(...more)
cursor = nextCursor
} while (cursor)
return rows
}
// A page of results never carries bodies; one card does.
export async function getItem(itemId: string): Promise<ItemDetail> {
const { data, error } = await cms.GET("/workspaces/{workspaceId}/items/{itemId}", {
params: { path: { workspaceId, itemId }, query: { version: "published" } },
})
if (error) throw new Error(`Spunto: ${error.error}`)
return data
}
// Attachments have no public URL: the build downloads them (step 5).
export async function getFileType(fileId: string): Promise<string> {
const { data, error } = await cms.GET("/workspaces/{workspaceId}/files/{fileId}", {
params: { path: { workspaceId, fileId } },
})
if (error) throw new Error(`Spunto: ${error.error}`)
return data.contentType
}
export async function getFileBytes(fileId: string): Promise<ArrayBuffer> {
const { response } = await cms.GET("/workspaces/{workspaceId}/files/{fileId}/content", {
params: { path: { workspaceId, fileId } },
parseAs: "stream",
})
if (!response.ok) throw new Error(`Spunto: file ${fileId} answered ${response.status}`)
return response.arrayBuffer()
}Three things the API does that this code relies on:
- reading is a
POST, because a view definition — filter tree, sorts — is a body (querying). This one has no filter: theversionalready restricts it to published cards; - pages are keyset cursors: loop until
nextCursorisnull. There is no page number to compute; - the published version is a read option, not a status. A card's status
(
To do,In progress…) is about the team's work; whether it is online is its publication. Moving a card on a board does not take it offline.
Types and defaults
ViewDefinition is typed from the response side of the spec, where fields that
have a default are required: write sort and includeSecondaryLists out, or
the file does not compile.
3. A content collection
Astro's content layer accepts a loader that is just an async function returning entries. The whole blog is one:
import { defineCollection } from "astro:content"
import { imageIds } from "./lib/blocks"
import { getFileType, getItem, listPublished } from "./lib/spunto"
const posts = defineCollection({
// Runs at build time (and when the dev server starts): the published posts,
// bodies included, plus where each of their images will be served from.
loader: async () => {
const rows = await listPublished()
return Promise.all(
rows.map(async (row) => {
const post = await getItem(row.id)
const images: Record<string, string> = {}
for (const fileId of imageIds(post.blocks.blocks)) {
const type = await getFileType(fileId) // "image/png" → "png"
images[fileId] = `/cms-files/${fileId}.${type.split("/")[1]}`
}
return {
id: String(post.properties.slug ?? post.id),
title: post.title,
icon: post.icon ?? null,
publishedOn: (post.properties.published_on as string | undefined) ?? null,
tags: (post.properties.tags as string[] | undefined) ?? [],
blocks: post.blocks.blocks,
mentions: post.mentions ?? [],
images,
}
}),
)
},
})
export const collections = { posts }The entry's id is the slug, so it is the URL. Property values are read by
their key (slug, published_on, tags) in post.properties; a filter,
should you add one, names a property by its fieldId instead. Mixing the
two is the classic 400 (limits).
The loader makes one request per post for its body, plus one per image. For a blog of a few hundred posts that is a few seconds of build; there are no rate limits today, which is not a promise.
4. The pages
---
import { getCollection } from "astro:content"
const posts = await getCollection("posts")
---
<html lang="en">
<head><meta charset="utf-8" /><title>Blog</title></head>
<body>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li><a href={`/blog/${post.id}/`}>{post.data.icon} {post.data.title}</a></li>
))}
</ul>
</body>
</html>---
import { getCollection } from "astro:content"
import Blocks from "../../components/Blocks.astro"
import { tree } from "../../lib/blocks"
export async function getStaticPaths() {
const posts = await getCollection("posts")
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }))
}
const { post } = Astro.props
---
<html lang="en">
<head><meta charset="utf-8" /><title>{post.data.title}</title></head>
<body>
<article>
<h1>{post.data.title}</h1>
{post.data.publishedOn && <time datetime={post.data.publishedOn}>{post.data.publishedOn}</time>}
<Blocks nodes={tree(post.data.blocks)} mentions={post.data.mentions} images={post.data.images} />
</article>
</body>
</html>getCollection returns entries in the order the loader gave them, which is the
sort of step 2 — newest first.
5. Render the body
A body is a flat 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: the components
below build elements, and there is no set:html anywhere.
Two things need grouping first — consecutive list items become one list, and a
toggle owns the blocks that follow it with a greater indent:
import type { Block } from "./spunto"
export type Node =
| { kind: "list"; ordered: boolean; items: Block[] }
| { kind: "toggle"; block: Block; children: Node[] }
| { kind: "block"; block: Block }
export function tree(blocks: Block[]): Node[] {
const out: Node[] = []
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({ kind: "toggle", block, children: tree(blocks.slice(i + 1, end)) })
i = end - 1
} else if (block.type === "bulleted" || block.type === "numbered") {
const items: Block[] = []
while (blocks[i]?.type === block.type) items.push(blocks[i++])
i--
out.push({ kind: "list", ordered: block.type === "numbered", items })
} else {
out.push({ kind: "block", block })
}
}
return out
}
/** Every image a body shows, so the build can download them. */
export function imageIds(blocks: Block[]): string[] {
return blocks.flatMap((b) => (b.type === "image" && b.fileId ? [b.fileId] : []))
}Marks nest — a bold link is <a><strong>…</strong></a> — so a span is rendered
by a component that wraps one mark per level and calls itself for the rest:
---
import type { Block } from "../lib/spunto"
type Span = NonNullable<Block["text"]>[number]
type Mark = "link" | "strike" | "italic" | "bold" | "code"
interface Props { span: Span; marks: Mark[] }
// One mark per level, outermost first: <a><s><em><strong><code>text…
const { span, marks } = Astro.props
const [mark, ...rest] = marks
---
{!mark && span.text}
{mark === "link" && <a href={span.link}><Astro.self span={span} marks={rest} /></a>}
{mark === "strike" && <s><Astro.self span={span} marks={rest} /></s>}
{mark === "italic" && <em><Astro.self span={span} marks={rest} /></em>}
{mark === "bold" && <strong><Astro.self span={span} marks={rest} /></strong>}
{mark === "code" && <code><Astro.self span={span} marks={rest} /></code>}---
import type { Block } from "../lib/spunto"
import Marks from "./Marks.astro"
type Span = NonNullable<Block["text"]>[number]
type Mention = { id: string; key: string; title: string }
interface Props { spans?: Span[]; mentions: Mention[] }
const { spans = [], mentions } = Astro.props
const order = ["link", "strike", "italic", "bold", "code"] as const
---
{spans.map((span) => {
// A mention: the API resolves it in `mentions`. Missing (deleted,
// unpublished) → something neutral, never the title the span remembers.
if (span.mention) {
const card = mentions.find((m) => m.id === span.mention)
return <span class="mention">{card ? `${card.key} ${card.title}` : "unavailable card"}</span>
}
return <Marks span={span} marks={order.filter((mark) => span[mark])} />
})}---
import type { Node } from "../lib/blocks"
import Text from "./Text.astro"
type Mention = { id: string; key: string; title: string }
interface Props { nodes: Node[]; mentions: Mention[]; images: Record<string, string> }
const { nodes, mentions, images } = Astro.props
const headings = ["h2", "h3", "h4"] as const
---
{nodes.map((node) => {
if (node.kind === "list") {
const List = node.ordered ? "ol" : "ul"
return <List>{node.items.map((item) => <li><Text spans={item.text} mentions={mentions} /></li>)}</List>
}
if (node.kind === "toggle") {
return (
<details>
<summary><Text spans={node.block.text} mentions={mentions} /></summary>
<Astro.self nodes={node.children} mentions={mentions} images={images} />
</details>
)
}
const block = node.block
switch (block.type) {
case "heading": {
const H = headings[(block.level ?? 1) - 1]
return <H><Text spans={block.text} mentions={mentions} /></H>
}
case "paragraph":
return <p><Text spans={block.text} mentions={mentions} /></p>
case "quote":
return <blockquote><Text spans={block.text} mentions={mentions} /></blockquote>
case "callout":
return <aside><Text spans={block.text} mentions={mentions} /></aside>
case "todo":
return <p><input type="checkbox" checked={!!block.checked} disabled /> <Text spans={block.text} mentions={mentions} /></p>
case "code":
return <pre><code data-language={block.language}>{block.code}</code></pre>
case "divider":
return <hr />
case "image":
return <img src={images[block.fileId!]} alt={block.alt ?? ""} loading="lazy" />
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 class="bookmark" href={block.url}>{block.title ?? new URL(block.url!).hostname}</a>
default:
// A type this renderer doesn't know yet: its text if it has any.
// `type` is an open string — a new block type won't break the build.
return block.text ? <p><Text spans={block.text} mentions={mentions} /></p> : null
}
})}Headings start at h2 because the post title is the page's h1. 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 build 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.
mentionscarries the current key and title of every card the body cites. Read withversion=published, a card that is not published is not in it: render something neutral, never the span'stext.
6. Copy the images into the build
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). A static site has no
server to relay them at request time, so it downloads them once, at build
time, with a static endpoint:
import type { APIRoute } from "astro"
import { getCollection } from "astro:content"
import { getFileBytes } from "../../lib/spunto"
// Every image of every published post becomes a file of the build.
export async function getStaticPaths() {
const posts = await getCollection("posts")
const images = Object.assign({}, ...posts.map((post) => post.data.images))
return Object.entries(images).map(([fileId, path]) => ({
params: { file: String(path).slice("/cms-files/".length) },
props: { fileId },
}))
}
export const GET: APIRoute = async ({ props }) => {
return new Response(await getFileBytes(props.fileId))
}The build writes dist/cms-files/<fileId>.png next to the pages, and your host
serves it like any other asset. The extension comes from the file's type in
step 3: without one, a static host serves the file without a usable
Content-Type.
7. Rebuild when a post is published
Netlify, Vercel and Cloudflare Pages all give a project a build hook: a
secret URL that starts a build when it receives a POST. Spunto can call it.
Under Webhooks (/w/{workspaceId}/settings/webhooks, for an admin of
the workspace), add an endpoint:
- URL: your host's build hook;
- Subscription: Publications only —
item.publishedanditem.unpublished.
Not Cards (item.*): the editor saves a draft about once a second while
someone types, and every save is an item.updated. None of that changes what
the site shows, and each one would start a build. With publications only, the
site rebuilds when someone presses Publish or Unpublish, and at no
other time.
- The build hook does not check the signature. Every delivery is signed
(
X-Spunto-Signature, events and webhooks), but a build hook ignores the body: the secret is the hook's URL. Keep it out of your repository. - A failed delivery is retried five times over about an hour and a quarter, so a host that is briefly down still gets the build.
- Each publication is one delivery. There is no publish-many route: publishing ten posts in a row calls the hook ten times, and how those builds are queued is up to your host.
Already in Notion?
What changes when a site stops reading from the Notion API, point by point, is in Notion as a headless CMS. The import route and how to make it replayable are in imports you can run twice.
What this guide does not cover
- Previews of drafts. A key that can read a workspace can read its drafts:
a preview build is the same loader without
version: "published", on a deployment only your team can open. - Pages rendered on request. The functions of
src/lib/spunto.tswork the same in a server-rendered Astro page; images then need a relaying endpoint instead of static files, as in the Next.js guide. - 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.
Every route used here is in the API reference, generated from the same specification your types came from.