All posts
7 min read·Jul 2026

Strapi as a headless CMS, and the parts that bite in production

Headless CMS development looks clean in the demo and gets messy the week you deploy. Here's what actually breaks with Strapi — content modeling, the populate trap, schema migrations — and how we ship around it.

Every headless CMS pitch shows the same three slides: a clean content type, a JSON response, a React component rendering it. It works because the demo has one blog post and no editors. Week three is when it turns: a marketer nests a callout inside a section inside a dynamic zone five levels down, an editor asks why the field they added on staging isn't on production, and your article page is firing four REST calls — one for the body, one for the author, one for related posts, one for the SEO block — to paint a single view. None of that is in the getting-started guide.

We build Strapi backends for B2B clients, usually behind a Next.js or Astro front end, and the decisions that decide whether the thing survives are never the ones on the feature comparison. Strapi itself is fine. Projects sink when a team treats it as a database with a nice admin panel instead of what it is: a content API you have to design field by field, populate by populate, exactly as deliberately as any API you'd hand to a paying integrator.

Model content for the query, not the admin form

The instinct is to model content the way it reads in the editor — a page holding sections holding components holding sub-components. Strapi will happily let you nest repeatable components and dynamic zones with no depth limit, and it looks tidy in the admin. Then the front end has to fetch it, and every layer is another entry in your populate object, another TypeScript shape to hand-write, another spot where an editor left a field blank and your `.map()` throws on undefined. A five-level content tree is a five-level null-checking problem on the render side.

So we model backward from the queries the front end actually makes. The article list needs `title`, `slug`, `excerpt`, and one thumbnail — those are flat fields on the article, not buried in a body component you'd have to populate just to build a card grid. Flatten what you list, nest only what a detail view renders, and reserve dynamic zones for layouts that are genuinely variable page to page — a landing-page builder, not every article that shares one fixed structure. Editor ergonomics are a constraint on the schema, not the schema itself.

The populate trap

Strapi's REST API returns none of your relations, media, or components by default — you opt in per request with `populate`, and this one parameter is behind most of the performance tickets we inherit. Reach for `populate=*` and Strapi walks every relation one level deep, firing a separate query per relation: the textbook N+1, so a single `/api/articles/:id` quietly becomes ten or fifteen Postgres round trips. Add a related-posts widget that itself populates authors and cover images and you've nested the storm — one HTTP request fanning into dozens of joins, which is exactly the shape that pins a small Postgres instance at 100% CPU the first time real traffic hits.

The fix is boring and it holds: name every field you populate, and pair it with field selection so you ship `excerpt` on a list endpoint instead of the entire 4,000-word body. Put a cache in front of anything read-heavy — Strapi's REST cache plugin or a CDN keyed on the query string — because a blog post is read thousands of times between edits and recomputing it per request is pure waste. For a page that needs one precisely-shaped payload we reach for GraphQL: a single typed query beats stitching four REST responses in the component. It is not automatically faster, though — an over-broad GraphQL selection N+1s just as hard, so it demands the same discipline about what you ask for.

Schema lives in code, content lives in the database

This is the one that ambushes teams. In Strapi your content-type definitions are JSON files in `src/api/**/schema.json` — they ship in the repo and deploy with your code. Your content, plus roles, permissions, and API tokens, live in the database. Promote staging to production and the schema moves while the data stays put, so the two drift. An editor adds a field through the admin UI on production; a developer adds a different field in code; the next deploy regenerates the schema and one of those two changes silently loses. We've taken over projects where nobody had deployed in months because staging and production had quietly forked and every push risked dropping a live column.

Treat schema as code with no exceptions: change it in a branch, in one place, in review, then deploy. Never add or edit a content type through the admin panel on production — that path writes to a database Strapi expects code to own. For the things that live in the database but must match across environments — roles, permissions, default settings — use config-sync or a seed script so a new environment is reproducible rather than hand-tuned by whoever set it up. If you can't stand up a fresh, working CMS from `git clone` plus a seed step, you don't have a deployable system; you have a pet that happens to run in production.

Media, previews, and knowing when the page is stale

Three things break specifically at the front-end seam. Media first: Strapi's default upload provider writes to local disk and returns `/uploads/...` paths that vanish the next time the container redeploys, so you wire up the S3 or Cloudinary provider on day one, not the afternoon the images start 404-ing. Preview second: editors expect to see a draft before it's public, which means connecting Strapi's draft-and-publish state to Next.js draft mode behind a signed, short-lived URL — not emailing someone a link to the raw `?status=draft` API. Third, and the one most teams miss: cache invalidation. If you statically render or cache responses, hitting Publish changes the database and nothing else — the live page keeps serving the old copy until something tells it to rebuild.

That something is a webhook. Strapi fires `entry.publish`; your Next.js route handler catches it and calls `revalidatePath` on the affected URLs, or your build pipeline triggers a redeploy. Skip it and you earn the worst class of bug report: the editor swears they published, the page still shows last week's headline, and nobody can reproduce it because the stale layer is a CDN cache no one wrote down. We wire publish-to-revalidate before the first content type ships, because "why isn't my change showing up" is the single most common thing a headless setup gets blamed for — usually wrongly.

When headless is the wrong call

Headless earns its cost when you have more than one front end reading one source — a marketing site, a mobile app, and an in-product help center all pulling the same articles — plus a real content team and a genuine reason to keep content and application logic apart. What you pay for it is an extra service to run and patch, an API contract to version, and a preview-and-cache story you build and maintain yourself. If the client has one brochure site that one person updates monthly, WordPress or a hosted CMS ships in a fraction of the time and costs almost nothing to keep alive, and we tell them exactly that. Picking headless because it sounds modern is how a five-page site ends up paying the operational tax of a content platform.

Keep reading
All posts

If you're planning a headless CMS build and want it to survive production, talk to us about scoping it right.