Wiring a Headless CMS to a Static Build
A CMS earns its place on a static site when the people who should be writing will not use a code host, or when the front matter has grown structured enough that hand-editing YAML produces errors. It does not earn its place merely because a site has content — plenty of documentation teams are better served by Markdown in a repository and a good review flow.
This guide covers both shapes: a Git-backed CMS that commits Markdown to your repository, and an API-backed CMS the build fetches from. It shows the content model, the authentication, the rebuild trigger and the media path, and it keeps the repository authoritative wherever possible. It is part of Content Workflows for Documentation Teams.
Prerequisites
- A static site whose content already has consistent front matter — the CMS describes what exists, so consistency comes first.
- A deploy pipeline triggered by pushes to the default branch.
- For an API-backed CMS: somewhere to store an access token that the build can read but the repository cannot leak.
Two Architectures, Two Trade-offs
Git-Backed: Describe the Content You Already Have
A Git-backed CMS is configuration, not migration. You describe the front matter that exists, and it edits the files that exist.
# public/admin/config.yml — Decap-style configuration
backend:
name: github
repo: acme/docs-site
branch: main
# Editorial workflow creates a branch + pull request per entry instead of
# committing straight to main — this is what keeps review in the loop.
open_authoring: false
publish_mode: editorial_workflow
media_folder: content/uploads
public_folder: /uploads
collections:
- name: guides
label: Guides
folder: content/guides
create: true
slug: '{{slug}}'
path: '{{slug}}/index'
fields:
- { name: title, label: Title, widget: string, pattern: ['^.{10,70}$', '10–70 characters'] }
- { name: description, label: Description, widget: text,
pattern: ['^.{80,158}$', '80–158 characters for search snippets'] }
- { name: date, label: Publish date, widget: datetime }
- { name: draft, label: Draft, widget: boolean, default: true }
- { name: body, label: Body, widget: markdown }
Two settings carry most of the value. publish_mode: editorial_workflow makes each entry a branch and a pull request, so the preview and review process in the parent guide applies unchanged. And the field pattern validations put the description-length rule in front of the author at the moment they write it, rather than in a build failure later.
Authentication is the part that surprises people: a browser-based CMS cannot hold a repository token, so it uses an OAuth flow through a small backend. Most hosts provide one; on Cloudflare or Netlify it is a few lines of Worker or function code, and it is worth confirming who can log in before pointing anyone at the URL.
API-Backed: Fetch at Build Time, Cache Deliberately
When content lives in a hosted store, the build fetches it. Write that fetch as a discrete step that produces files on disk, rather than scattering API calls through templates:
// scripts/fetch-content.mjs — run before the build
import { mkdir, writeFile } from 'node:fs/promises';
import matter from 'gray-matter';
const res = await fetch(`${process.env.CMS_URL}/api/guides?limit=500`, {
headers: { authorization: `Bearer ${process.env.CMS_TOKEN}` },
});
if (!res.ok) throw new Error(`content fetch failed: ${res.status} ${res.statusText}`);
const { items } = await res.json();
await mkdir('content/guides', { recursive: true });
for (const item of items) {
const front = {
title: item.title,
description: item.description,
date: item.publishedAt,
cmsId: item.id, // keeps the round trip traceable
};
await writeFile(`content/guides/${item.slug}.md`, matter.stringify(item.body, front));
}
console.log(`fetched ${items.length} entries`);
Three properties make this worth the extra file. The build fails loudly if the API is unavailable, instead of silently producing a site with missing pages. The fetched Markdown can be committed to a cache branch so a build is reproducible without the vendor. And every downstream tool — link checks, spell checks, the generator itself — sees ordinary Markdown, so nothing else in the pipeline has to know a CMS exists.
Trigger Rebuilds Without Thrashing
A publish event should produce exactly one build. Without debouncing, an editor fixing five typos produces five builds, each cancelling or queueing behind the last.
// functions/cms-hook.js — debounced deploy trigger at the edge
export async function onRequestPost({ request, env }) {
const sig = request.headers.get('x-cms-signature');
if (sig !== env.CMS_WEBHOOK_SECRET) return new Response('forbidden', { status: 403 });
const now = Date.now();
const last = Number((await env.KV.get('last-build')) || 0);
if (now - last < 120_000) { // 2-minute debounce window
await env.KV.put('pending', '1');
return new Response('debounced', { status: 202 });
}
await env.KV.put('last-build', String(now));
await fetch(env.DEPLOY_HOOK_URL, { method: 'POST' });
return new Response('triggered', { status: 202 });
}
Verify the signature: a deploy hook URL that anyone can call is a free denial-of-wallet on your build minutes. The scheduled-rebuild pattern in Scheduling Content Publication With Cron-Triggered Builds pairs naturally with the debounce, since the cron sweeps up anything the window swallowed.
Media: Constrain at the Source
Uploads are where a CMS most often damages a static site. A phone photo is 4-8 MB, and an editor with no guidance will happily insert one at full size.
Constrain in the CMS configuration (maximum dimensions and file size on the image widget), store the original in the repository or the CMS media store, and let the build produce the responsive variants. Never serve the upload directly — the pipeline described in Image Optimization Pipelines in Astro exists for exactly this, and it also stamps the dimensions that keep layout stable.
Add a build check as the backstop:
# Fail the build if any committed upload exceeds 1.5 MB
find content/uploads -type f -size +1500k -printf '%s\t%p\n' \
| tee /dev/stderr | grep -q . && { echo "oversized upload(s) — resize before merging"; exit 1; }
exit 0
Measured Impact
A documentation team of eleven, of whom three were comfortable with a repository, over one quarter before and after adding a Git-backed CMS with editorial workflow:
| Measure | Before | After |
|---|---|---|
| Contributors publishing at least once | 3 | 9 |
| Median time from draft to published | 6.2 days | 0.6 days |
| Front-matter errors reaching review | 14 | 1 |
| Builds triggered per publishing day | 4.1 | 5.8 |
| Oversized images committed | 7 | 0 (blocked at upload) |
Pitfalls & Rollback
- Letting the CMS become the source of truth. With a Git-backed system the repository must stay authoritative; with an API-backed one, cache the fetched content so a build is reproducible without the vendor.
- Skipping the editorial workflow. Committing straight to the default branch skips preview and review, which is most of what made the pipeline trustworthy.
- Unsigned deploy hooks. A public trigger URL is an invitation to burn your build minutes.
- Modelling content around the CMS's widgets. Model it around the pages you publish; a schema that only makes sense inside one vendor's UI is a migration cost later.
- Unconstrained uploads. Set limits in the CMS and add a build check; an editor should not need to know what an AVIF is.
- Rollback: a Git-backed CMS is a configuration file and an auth backend. Deleting the admin route leaves every piece of content exactly where it was, because it was always just Markdown in the repository.
Conclusion
For documentation, a Git-backed CMS is usually the right answer: it adds an editing interface without moving the source of truth, so previews, review, history and offline builds all keep working. Reach for an API-backed system only when the content genuinely belongs to more than one product, and then write the fetch as an explicit build step that produces Markdown you could commit. Either way, validate fields at entry, debounce the rebuild, and constrain media before it reaches the repository. The surrounding workflow is in Content Workflows for Documentation Teams.
FAQ
Git-backed or API-backed CMS — which should I choose?
Git-backed for documentation, where the repository should stay authoritative and content benefits from branching, review and history. API-backed when content is shared across several products, needs fine-grained editorial permissions, or is edited by people who will never see a pull request.
How does the site rebuild when someone publishes?
A webhook from the CMS triggers a build. With a Git-backed CMS the commit itself triggers your normal pipeline; with an API-backed one you add a deploy hook the CMS calls on publish, usually with a short debounce so ten edits do not queue ten builds.
What happens to preview when content lives outside Git?
You lose per-branch previews unless the CMS supports draft states that the build can query. Most API-backed systems expose a preview token that renders unpublished entries, which you point a separate preview deployment at.
Where should uploaded images go?
Into the repository with a Git-backed CMS, and into the CMS's own media store with an API-backed one. Either way, run them through the build's image pipeline rather than serving originals, and set a size limit in the CMS so a 12 megapixel phone photo never reaches the build.
Can I add a CMS to an existing static site without changing the content?
Usually yes for a Git-backed CMS — you describe the existing front matter as a collection schema and it edits the files you already have. That is a strong argument for keeping front matter simple and consistent from the start.
Related
- Parent: Content Workflows for Documentation Teams — where the CMS fits among the entry points.
- Scheduling Content Publication With Cron-Triggered Builds — the backstop for debounced webhooks.
- Docs-as-Code Review Workflow for Writers — the review process the editorial workflow feeds.
- Netlify Build Hooks for Content Updates — the same trigger mechanism on another host.
- Image Optimization Pipelines in Astro — what should happen to every upload.