Studio notesEngineering

We took the blog off the CMS

Our articles used to be fetched from Notion. Now they are JSON files in the repository, and the whole thing builds with no network call. What that traded away, and when you should not do it.

Mainichi7 min read
Copied

In short

  • A headless CMS buys editing for non-technical people. If nobody non-technical is editing, you are paying for nothing.
  • Content in the repository gets review, history, search and atomic deploys for free.
  • The migration is one-way in practice. Export the images too, or you have moved the dependency rather than removed it.
  • Keep the CMS the moment someone outside the codebase needs to publish without you.

Every article on this site used to live in Notion and be fetched through its API. That is a completely standard architecture, it is what most Next.js blog tutorials will hand you, and for us it was the wrong one for about two years before we noticed.

What a headless CMS is actually for

It solves one problem well: letting people who do not touch the codebase publish and edit without a developer. That is a real and valuable problem, and if you have it, none of the following applies to you.

We did not have it. Every article was written by the same people who write the code, in a tool that was strictly worse at it than the editor they already had open, and then read back out through an API on every build.

What it was costing

  • Builds could fail for reasons unrelated to the code. An API rate limit or an outage becomes a failed deploy of a page whose content has not changed in a year.
  • The schema was theirs, not ours. Blocks came back in whatever shape the API produced, so the renderer was written against someone else's model, including block types we never used and quirks we had to defend against.
  • Images were hotlinked to signed URLs. That is the one that bites. Asset URLs on hosted CMSs expire, which means either proxying them or re-fetching, and a remote host allowlist in your image config that grows quietly.
  • No review, no history, no grep. A typo fix in an article left no trace next to the code, and finding every article that mentioned a deprecated product meant searching a different application.

What we moved to

One JSON file per article, in the repository, with an index that imports them. That is the entire system:

src/content/articles/index.js
import scrollDriven3d from './scroll-driven-3d-without-wrecking-your-page.json'
import designSubscription from './design-subscription-how-it-works.json'
// ...

export const articles = [scrollDriven3d, designSubscription /* ... */]
Static imports, so unused articles are still bundled but the whole set is known at build time and nothing is fetched.

The route then prerenders everything and refuses anything outside the set:

app/blog/[slug]/page.jsx
// The archive is local and complete at build time, so every article is
// prerendered and anything outside the list is a hard 404.
export const dynamicParams = false

export function generateStaticParams() {
  return getAllArticles().map((article) => ({ slug: article.slug }))
}
dynamicParams false is the line that turns a nice-to-have into a guarantee.

Images moved into the repository alongside the code, so there is no remote host to allowlist and no signed URL to expire.

The migration

We wrote a one-shot script that walked the API, normalised every block into a shape we chose rather than one we inherited, downloaded every image, and wrote the files. Then we deleted the script.

The part that takes the actual time is images. Exporting text is trivial. Exporting every asset, rewriting every reference to a local path, and checking that nothing still points at a URL that will expire is most of the work, and skipping it means you have moved the dependency rather than removed it.

What we got

  • Builds that cannot fail on someone else's uptime. There is no network call in the content path at all.
  • A schema we own. When the blog needed callouts, key takeaways and code blocks with captions, adding them was a field in a JSON file and a case in a switch, not a negotiation with someone else's block model.
  • Review and history. An article edit is a diff. Retiring ten posts and redirecting their URLs was one commit that a person could read and, if necessary, revert.
  • grep. Unglamorous, and the thing we use most.

What we gave up, honestly

Publishing now requires a deploy. Writing happens in an editor with no preview of the rendered result until you run the site. Nobody outside the codebase can fix a typo. Rich text is authored as structured JSON, which is more verbose than prose in a nice editor and would be genuinely miserable for a team publishing weekly.

If any of those matter to you, keep the CMS. The correct version of this decision is not that CMSs are bad. It is that a CMS is a tool for a specific problem, and running one when you do not have that problem costs you real reliability and control in exchange for a convenience nobody on the team is using.

next.jscontentarchitectureperformance