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 versus API-backed CMS architecture Two architectures. In the Git-backed model the editor commits Markdown to the repository, which triggers the normal build and deploy pipeline, so the repository stays the source of truth. In the API-backed model the editor writes to a hosted content store, a webhook triggers a build, and the build fetches content over the network at build time. Where the content actually lives Git-backed CMS editor UI commit Markdown repo is authoritative normal CI deploy API-backed CMS editor UI hosted content store vendor is authoritative webhook → build fetches deploy Git-backed: branch previews, review, history and offline builds all keep working unchanged API-backed: richer editorial features, but the build now depends on a network service being up
The architectural question is not which UI is nicer — it is whether a build six months from now can be reproduced from the repository alone.

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.

Publish events with and without debouncing Two timelines over ten minutes. Without debouncing, six publish events produce six builds, several overlapping and cancelling each other. With a two-minute debounce, the same six events produce two builds, and a scheduled sweep catches anything published inside the final window. Six edits, two builds No debounce build build build build build build 2-minute debounce build · covers 3 edits build · covers 3 edits 0 min 5 min 10 min The hourly scheduled build is the backstop for an edit that lands in the last debounce window
Debouncing costs a couple of minutes of publish latency and removes most of the build minutes — and, on hosts that cancel in-flight builds, it removes a class of confusing half-deploys.

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:

MeasureBeforeAfter
Contributors publishing at least once39
Median time from draft to published6.2 days0.6 days
Front-matter errors reaching review141
Builds triggered per publishing day4.15.8
Oversized images committed70 (blocked at upload)
Contributors and errors before and after adding the CMS Two paired bars. Contributors publishing at least once rose from three to nine out of eleven team members. Front-matter errors reaching review fell from fourteen to one, because the CMS validated fields at entry time. Same team, same repository, different front door Publishing contributors 3 of 11 9 of 11 Front-matter errors 14 reached review 1 One quarter before, one quarter after · validation at entry time is what removed the errors
The error reduction is the underrated half: the same validation rules existed as build gates before, but catching them at the moment of writing is far cheaper than catching them in CI.

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.