Next.js avec un CMS headless : guide pas à pas
Spunto CMS comme CMS headless d'un site Next.js : client typé généré depuis l'OpenAPI, server components, rendu des blocs, images et revalidation par webhook.
Ce guide construit le premier projet classique : un blog sous Next.js (App Router) dont les articles s'écrivent dans un espace de travail Spunto. La rédaction travaille dans des listes et des cartes ; le site lit les mêmes lignes en REST et se met à jour quand une carte change.
Il n'y a ni export ni second stockage entre les deux — la carte que
modifie un rédacteur est l'enregistrement que lit le site. Tout ce qui suit
tourne contre https://cms.spunto.net avec un compte gratuit.
1. Modéliser le contenu
Dans un espace de travail, créez une liste Articles de blog avec un préfixe
de clé comme BLOG. Puis, dans les réglages de l'espace :
- dans Statuts (
/w/{workspaceId}/settings/statuts), ajoutez un statut nomméPublished. Les statuts appartiennent à l'espace, donc toutes les listes les partagent ; un article est en ligne quand il porte celui-ci ; - dans Propriétés (
/w/{workspaceId}/settings/proprietes), ajoutez une propriététextnomméeSlug. Sa clé de stockage est dérivée du nom — vérifiez qu'elle vaut bienslug.
Rédigez un article ou deux : un titre, un slug, un corps, et le statut
Published pour ceux qui doivent paraître.
Raccourci
Sur un espace tout neuf, la vue d'ensemble propose Créer le blog d'exemple :
il fait toute cette étape à votre place — la liste, le statut Published, la
propriété slug, plus une date Publié le et des Étiquettes — avec trois
articles, dont deux publiés. Il faut le rôle admin, et il n'apparaît que tant
que l'espace n'a aucune liste.
Un statut, ou une vraie publication
Ce guide met un article en ligne par un statut : il est présent sur chaque
carte, il devient une colonne dans un kanban, et un rédacteur le change d'un
glisser-déposer — mais le site lit alors l'article pendant qu'on l'écrit.
Les cartes ont aussi un brouillon et une version publiée
intégrés : ajoutez "version": "published" aux requêtes ci-dessous, retirez le
filtre sur le statut, et le site sert le dernier texte publié pendant que le
brouillon suivant s'écrit.
Deux identifiants se lisent dans la barre d'adresse de la liste,
/w/{workspaceId}/c/{collectionId}. Créez ensuite une clé API avec le rôle
viewer dans Clés API (/w/{workspaceId}/settings/api, détails).
Un site ne fait que lire ; une clé qui peut écrire est une clé qu'il faut
protéger davantage.
SPUNTO_TOKEN=spk_...
SPUNTO_WORKSPACE_ID=...
SPUNTO_COLLECTION_ID=...2. Un client typé en deux commandes
L'API est décrite par une spécification OpenAPI servie à
/openapi.json. Générez-en les types, et appelez l'API avec
openapi-fetch :
npm install openapi-fetch server-only
npx openapi-typescript https://cms.spunto.net/openapi.json -o lib/spunto-api.d.tsRelancez la seconde commande chaque fois que vous voulez les types d'une API plus récente. Un champ n'est jamais ajouté à une réponse autrement qu'en optionnel : un ancien fichier généré continue donc de fonctionner face à un serveur plus récent.
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}` },
})
// Un seul appel renvoie la liste, ses propriétés et les statuts de l'espace :
// on résout ici les noms en identifiants plutôt que de recopier des id dans l'env.
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
// Une page de résultats ne porte jamais les corps ; une carte seule, si.
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)
}Ce code s'appuie sur trois comportements de l'API :
- un filtre est un arbre, envoyé dans le corps d'un
POST— c'est pourquoi la lecture passe parPOST …/query(requêtes) ; - les pages sont des curseurs keyset : bouclez jusqu'à ce que
nextCursorvaillenull. Il n'y a aucun numéro de page à calculer ; - les valeurs des propriétés sont indexées par
keydansitem.properties, alors qu'un filtre désigne une propriété par sonfieldId. Confondre les deux, c'est le 400 classique (limites).
server-only est là pour le jeton : ce module ne doit jamais se retrouver dans
un bundle client.
3. Les 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
// Prérend ce qui est publié au moment du build ; un article publié plus tard
// est rendu à sa première requête, puis mis en cache comme les autres.
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>
)
}Les deux pages sont statiques, avec un rafraîchissement horaire en secours ; l'étape 6 les met à jour dès qu'un rédacteur modifie quelque chose.
4. Rendre le corps
Un corps est une liste de blocs typés, et le texte d'un bloc est une liste de
spans — une chaîne plus des marques (corps des cartes).
Le format ne peut pas transporter de HTML : il n'y a donc rien à assainir, et
aucun dangerouslySetInnerHTML nulle part :
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) => {
// Une mention : l'id d'une carte. Clé et titre à jour dans `post.mentions` ;
// absente de la liste (supprimée, non publiée) → rendu neutre, sans le titre.
if (span.mention) {
const card = mentions.get(span.mention)
return <span key={i} className="mention">{card ? `${card.key} ${card.title}` : "carte indisponible"}</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` est recomposé par l'API depuis une liste blanche : jamais `url`.
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":
// Un dépliable : son contenu est ce qui le suit avec un `indent` plus grand.
return <details><summary><Text spans={block.text} /></summary>{inner}</details>
default:
// Un type que ce rendu ne connaît pas encore : son texte s'il en a, rien sinon.
// `type` est une chaîne ouverte — un nouveau type ne casse pas ce 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") {
// Des blocs `bulleted` / `numbered` consécutifs forment une seule liste.
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>
)
}Les titres commencent à h2 parce que le titre de l'article est le h1 de la
page. Chaque bloc porte aussi un indent (de 0 à 5) : ce rendu s'en sert pour
le contenu d'un dépliable, et l'ignore ailleurs.
Trois choses à garder en l'adaptant :
- Gardez toujours un cas
default. En réponse,Block.typeest une chaîne ouverte : l'API ajoute des types de blocs avec le temps, et un site bâti aujourd'hui doit rendre le corps de demain — en dégradé, pas en panne. - L'iframe d'un embed prend
embedUrl, jamaisurl. L'API le recalcule à chaque écriture depuis une liste blanche (YouTube, Vimeo, Loom, Figma, Google Docs, Sheets et Slides, CodeSandbox, CodePen) ;urlest la page qu'un lecteur ouvrirait. - Une mention est résolue par l'API, pas par vous.
post.mentionsporte la clé et le titre à jour de chaque carte que le corps cite. Une carte supprimée — ou, en lecture?version=published, non publiée — n'y est pas : rendez quelque chose de neutre, jamais letextdu fragment, qui est le titre qu'avait la carte quand on l'a citée. Lier une mention à votre propre page est une recherche de votre côté (soniddans votre liste d'articles).
5. Servir les images
Un bloc image porte un fileId, jamais une URL. Les pièces jointes n'ont
pas d'adresse publique dans Spunto : l'API sert elle-même les octets, derrière
le même jeton bearer que tout le reste (fichiers). Le site
les relaie donc par une route à lui, ce qui garde le jeton côté serveur :
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",
},
})
}Un identifiant de fichier qui n'appartient pas à votre espace répond 404, comme tout ce qui n'est pas à vous.
Le type servi n'est pas le type envoyé
Les images et les PDF reviennent avec leur type. Tout ce qui sort de cette
liste — SVG compris — est servi en application/octet-stream, volontairement :
un SVG est un document scriptable.
6. Mettre le site à jour quand le contenu change
Spunto inscrit chaque modification dans un journal d'événements et peut la pousser vers un webhook. Faites-en pointer un vers le site, et revalidez :
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 })
}Ensuite, dans Webhooks (/w/{workspaceId}/settings/webhooks, réservé à un
admin de l'espace), ajoutez un point de terminaison d'URL
https://your-site.example/api/revalidate avec le motif d'événements item.*.
Son secret de signature n'est affiché qu'une fois : placez-le dans
SPUNTO_WEBHOOK_SECRET.
- L'horodatage est dans la signature (
t.body) : une livraison interceptée ne peut pas être rejouée plus tard — c'est ce que vérifie la fenêtre de cinq minutes. - Une livraison échouée est retentée cinq fois sur environ une heure et quart, et rien n'est perdu si votre site est indisponible : le journal reste lisible à partir d'un curseur.
- Un import ou une édition en masse envoie une seule livraison pour le lot, pas une par carte : publier 300 articles d'un coup ne revalide qu'une fois.
revalidatePath("/blog", "layout") jette toutes les pages sous /blog. C'est le
bon défaut pour un blog, et la solution la plus simple qui ne soit jamais
périmée. Un site plus gros peut lire event.resource.id dans la charge utile et
ne revalider que la page qui a changé.
Déjà sur Notion ?
Ce qui change, point par point, quand un site cesse de lire l'API de Notion est détaillé dans Notion comme CMS headless.
Si vos articles vivent aujourd'hui dans une base Notion,
POST /workspaces/{workspaceId}/import est la route faite pour les déplacer. La
requête nomme sa source (notion) et chaque carte porte l'identifiant de sa
page Notion en externalId : l'import est donc rejouable. Le lancer deux
fois met à jour au lieu de dupliquer, et une carte qui n'a pas changé n'est pas
réécrite du tout. La correspondance entre les propriétés et blocs de Notion et
ceux de Spunto est à votre charge ; les règles sont dans
les imports qu'on peut lancer deux fois.
Ce que ce guide ne couvre pas
- L'aperçu des articles non publiés. Filtrez sur un autre statut avec une seconde clé, de courte durée, ou ne rendez les brouillons que derrière votre propre authentification.
- La recherche dans le corps des articles. L'API n'indexe pas les corps ; la recherche de l'espace ne porte que sur les titres. Construisez votre propre index au build si vous en avez besoin.
- Les limites de débit. Il n'y en a aucune aujourd'hui, ce qui n'est pas une promesse. Des pages statiques revalidées par webhook gardent de toute façon le nombre de requêtes bas.
Toutes les routes utilisées ici figurent dans la référence de l'API, générée à partir de la même spécification que vos types.
Démarrage rapide
D'un compte vide à la lecture de votre propre contenu par l'API REST du CMS headless, en une dizaine de minutes.
Notion comme CMS headless : ce qui coince, et ce qui change
Un site qui lit l'API Notion : limites de débit, blocs imbriqués, URLs de fichiers qui expirent, webhooks sans contenu — et la réponse de Spunto CMS.