Spunto CMS
Guides

Astro avec un CMS headless : guide pas à pas

Spunto CMS comme CMS headless d'un site Astro : une content collection chargée en REST, typée depuis la spec OpenAPI publique, le rendu des blocs en composants .astro, les images copiées au build et une reconstruction à chaque publication.

Ce guide construit un blog statique sous Astro dont les articles s'écrivent dans un espace de travail Spunto. La rédaction travaille dans des listes et des cartes ; au build, une content collection d'Astro lit les cartes publiées en REST, et le site est reconstruit à chaque publication.

Il n'y a ni export ni second stockage entre les deux — la carte que publie un rédacteur est l'enregistrement que lit le site. Tout ce qui suit tourne contre https://cms.spunto.net avec un compte gratuit, et a été exécuté de bout en bout sous Astro 7.

1. Modéliser le contenu

La galerie Modèles (/w/{workspaceId}/templates, pour un admin de l'espace — un espace tout neuf la montre sur sa vue d'ensemble) contient un modèle Blog : Utiliser crée une liste Articles de blog (préfixe BLOG) avec une propriété slug, une date Publié le et des Étiquettes, et trois articles d'exemple. Pour le faire à la main : une liste, puis une propriété text nommée Slug dans Propriétés (/w/{workspaceId}/settings/proprietes) — vérifiez que sa clé vaut bien slug.

Puis publiez les articles qui doivent paraître : le bouton Publier dans l'en-tête d'une carte. Toute carte est un brouillon tant qu'elle n'est pas publiée, et ce site ne lit que la version publiée. Un rédacteur peut continuer à réécrire un article publié ; le site garde le dernier texte publié jusqu'à la publication suivante.

Deux identifiants viennent de la barre d'adresse de la liste, /w/{workspaceId}/c/{collectionId}. Créez ensuite une clé API au rôle viewer dans Clés API (/w/{workspaceId}/settings/api, détails). Un build ne fait que lire ; une clé qui peut écrire est une clé qu'il faut protéger davantage.

.env
SPUNTO_TOKEN=spk_...
SPUNTO_WORKSPACE_ID=...
SPUNTO_COLLECTION_ID=...

Astro lit .env par import.meta.env, et une variable sans le préfixe PUBLIC_ n'est jamais envoyée au navigateur. Dans un build statique, le jeton ne sert que sur la machine qui construit.

2. Un client typé en deux commandes

L'API est décrite par une spécification OpenAPI servie sur /openapi.json. Générez-en les types, et appelez-la avec openapi-fetch :

npm install openapi-fetch
npx openapi-typescript https://cms.spunto.net/openapi.json -o src/lib/spunto-api.d.ts

Relancez la seconde commande quand vous voulez les types d'une API plus récente. Un champ n'est jamais qu'ajouté à une réponse, et toujours en optionnel : un fichier généré ancien continue de marcher contre un serveur plus récent.

src/lib/spunto.ts
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"` : seulement les cartes publiées, telles qu'à leur publication.
// Ce qu'un rédacteur est en train d'écrire n'atteint jamais le 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
}

// Une page de résultats ne porte jamais les corps ; une carte seule, si.
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
}

// Les pièces jointes n'ont pas d'URL publique : le build les télécharge (étape 6).
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()
}

Trois choses que fait l'API et sur lesquelles ce code s'appuie :

  • lire est un POST, parce qu'une définition de vue — arbre de filtres, tris — est un corps de requête (requêtes). Celle-ci n'a pas de filtre : la version la restreint déjà aux cartes publiées ;
  • les pages sont des curseurs keyset : bouclez jusqu'à ce que nextCursor vaille null. Il n'y a pas de numéro de page à calculer ;
  • la version publiée est une option de lecture, pas un statut. Le statut d'une carte (À faire, En cours…) parle du travail de l'équipe ; ce qui est en ligne, c'est sa publication. Déplacer une carte dans un kanban ne la retire pas du site.

Types et valeurs par défaut

ViewDefinition est typée depuis le côté réponse de la spec, où un champ qui a une valeur par défaut est obligatoire : écrivez sort et includeSecondaryLists en entier, sinon le fichier ne compile pas.

3. Une content collection

La content layer d'Astro accepte un loader qui n'est qu'une fonction asynchrone renvoyant des entrées. Le blog entier en est une :

src/content.config.ts
import { defineCollection } from "astro:content"
import { imageIds } from "./lib/blocks"
import { getFileType, getItem, listPublished } from "./lib/spunto"

const posts = defineCollection({
  // S'exécute au build (et au démarrage du serveur de dev) : les articles publiés,
  // corps compris, et l'adresse d'où chacune de leurs images sera servie.
  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 }

L'id d'une entrée est le slug, donc l'URL. Les valeurs de propriétés se lisent par leur clé (slug, published_on, tags) dans post.properties ; un filtre, si vous en ajoutez un, nomme une propriété par son fieldId. Confondre les deux est le 400 classique (limites).

Le loader fait une requête par article pour son corps, plus une par image. Pour un blog de quelques centaines d'articles, cela fait quelques secondes de build ; il n'y a pas de limite de débit aujourd'hui, ce qui n'est pas une promesse.

4. Les pages

src/pages/blog/index.astro
---
import { getCollection } from "astro:content"

const posts = await getCollection("posts")
---
<html lang="fr">
  <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>
src/pages/blog/[slug].astro
---
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="fr">
  <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 rend les entrées dans l'ordre où le loader les a données, qui est le sort de l'étape 2 — les plus récentes d'abord.

5. Rendre le corps

Un corps est une liste plate de blocs typés, et le texte d'un bloc est une liste de spans — une chaîne plus des marques (corps de page). Le format ne peut pas transporter de HTML, donc il n'y a rien à assainir : les composants ci-dessous construisent des éléments, et il n'y a de set:html nulle part.

Deux choses se regroupent d'abord — des éléments de liste consécutifs forment une seule liste, et un bloc dépliable possède les blocs qui le suivent avec un indent plus grand :

src/lib/blocks.ts
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
}

/** Toutes les images d'un corps, pour que le build les télécharge. */
export function imageIds(blocks: Block[]): string[] {
  return blocks.flatMap((b) => (b.type === "image" && b.fileId ? [b.fileId] : []))
}

Les marques s'imbriquent — un lien en gras, c'est <a><strong>…</strong></a> — donc un span est rendu par un composant qui pose une marque par niveau et s'appelle lui-même pour le reste :

src/components/Marks.astro
---
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[] }

// Une marque par niveau, de l'extérieur vers l'intérieur : <a><s><em><strong><code>texte…
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>}
src/components/Text.astro
---
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) => {
  // Une mention : l'API la résout dans `mentions`. Absente (supprimée,
  // non publiée) → un rendu neutre, jamais le titre dont le span se souvient.
  if (span.mention) {
    const card = mentions.find((m) => m.id === span.mention)
    return <span class="mention">{card ? `${card.key} ${card.title}` : "carte indisponible"}</span>
  }
  return <Marks span={span} marks={order.filter((mark) => span[mark])} />
})}
src/components/Blocks.astro
---
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` est recalculée par l'API depuis une liste blanche : jamais `url` ici.
      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:
      // Un type que ce rendu ne connaît pas encore : son texte s'il en a un.
      // `type` est une chaîne ouverte — un nouveau type de bloc ne casse pas le build.
      return block.text ? <p><Text spans={block.text} mentions={mentions} /></p> : null
  }
})}

Les titres commencent à h2 parce que le titre de l'article est le h1 de la page. Trois choses à garder en l'adaptant :

  • Gardez toujours un cas default. Block.type est une chaîne ouverte dans les réponses : l'API ajoute des types de blocs avec le temps, et un site construit aujourd'hui doit construire le corps de demain — dégradé, pas cassé.
  • L'iframe d'un embed prend embedUrl, jamais url. L'API la recalcule à chaque écriture depuis une liste blanche (YouTube, Vimeo, Loom, Figma, Google Docs, Sheets et Slides, CodeSandbox, CodePen) ; url est la page qu'ouvrirait un lecteur.
  • Une mention est résolue par l'API, pas par vous. mentions porte la clé et le titre actuels de chaque carte que cite le corps. En lecture version=published, une carte non publiée n'y est pas : rendez quelque chose de neutre, jamais le text du span.

6. Copier les images dans le build

Un bloc image porte un fileId, jamais une URL. Les pièces jointes n'ont pas d'adresse publique dans Spunto : l'API sert les octets elle-même, derrière le même jeton que tout le reste (fichiers). Un site statique n'a pas de serveur pour les relayer à la requête, donc il les télécharge une fois, au build, avec un endpoint statique :

src/pages/cms-files/[file].ts
import type { APIRoute } from "astro"
import { getCollection } from "astro:content"
import { getFileBytes } from "../../lib/spunto"

// Chaque image de chaque article publié devient un fichier du 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))
}

Le build écrit dist/cms-files/<fileId>.png à côté des pages, et l'hébergeur le sert comme n'importe quel fichier. L'extension vient du type du fichier, lu à l'étape 3 : sans elle, un hébergeur statique sert le fichier sans Content-Type utilisable.

7. Reconstruire à chaque publication

Netlify, Vercel et Cloudflare Pages donnent tous trois à un projet un build hook : une URL secrète qui lance un build quand elle reçoit un POST. Spunto peut l'appeler. Dans Webhooks (/w/{workspaceId}/settings/webhooks, pour un admin de l'espace), ajoutez un endpoint :

  • URL : le build hook de votre hébergeur ;
  • Abonnement : Les publications seulement — item.published et item.unpublished.

Pas Les cartes (item.*) : l'éditeur enregistre le brouillon à peu près chaque seconde pendant qu'on écrit, et chaque enregistrement est un item.updated. Rien de tout cela ne change ce que montre le site, et chacun lancerait un build. Avec les publications seulement, le site se reconstruit quand quelqu'un appuie sur Publier ou Dépublier, et à aucun autre moment.

  • Le build hook ne vérifie pas la signature. Chaque livraison est signée (X-Spunto-Signature, événements et webhooks), mais un build hook ignore le corps : le secret, c'est l'URL du hook. Gardez-la hors de votre dépôt.
  • Une livraison échouée est retentée cinq fois sur environ une heure et quart : un hébergeur brièvement indisponible reçoit quand même son build.
  • Chaque publication est une livraison. Il n'y a pas de route pour publier en masse : publier dix articles d'affilée appelle le hook dix fois, et la façon dont ces builds font la queue dépend de votre hébergeur.

Déjà sur Notion ?

Ce qui change quand un site cesse de lire l'API Notion est détaillé, point par point, dans Notion comme CMS headless. La route d'import, et comment la rendre rejouable, sont dans les imports qu'on peut lancer deux fois.

Ce que ce guide ne couvre pas

  • L'aperçu des brouillons. Une clé qui peut lire un espace peut lire ses brouillons : un build d'aperçu, c'est le même loader sans version: "published", sur un déploiement que seule votre équipe peut ouvrir.
  • Les pages rendues à la requête. Les fonctions de src/lib/spunto.ts marchent de la même façon dans une page Astro rendue côté serveur ; les images demandent alors un endpoint qui relaie plutôt que des fichiers statiques, comme dans le guide Next.js.
  • La recherche dans les corps d'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.

Chaque route utilisée ici est dans la référence de l'API, générée depuis la même spécification que vos types.

Sur cette page