MDX vs a Headless CMS: Choosing by Who Writes the Posts
An editorial-workflow decision wearing an architecture decision's clothes. What MDX buys you in CI, the Turbopack plugin trap, and the four cases where a CMS simply wins.
Choose MDX when the people writing the posts are the people who can open a pull request, and a headless CMS when they are not. Everything else — cost, speed, type safety, query flexibility — follows from that one fact. It is an editorial-workflow decision wearing an architecture decision's clothes.
This blog runs on MDX: thirty posts compiled at build time, a typed metadata registry beside them, and a test suite that fails the build if the two drift apart. Below is what that actually costs and where it stops being the right answer.
The comparison that matters
| MDX in the repo | Headless CMS | |
|---|---|---|
| Who can publish | Anyone who can open a PR | Anyone with a login |
| Publishing latency | A deploy (minutes) | Seconds, no deploy |
| Review workflow | Code review, free | Whatever the CMS ships |
| Type safety on metadata | Compile-time, yours | Runtime, generated at best |
| Broken internal links | Catchable in CI | Found in production |
| Custom components in prose | Native — it is JSX | Embeds, shortcodes, or blocks |
| Structured querying | Whatever you write in TypeScript | A query API you get for free |
| Images | Your build pipeline | Their pipeline and CDN |
| Scheduled publishing | Cron or a manual merge | Built in |
| Localization | You build it | Usually built in |
| Runtime cost | Zero — compiles to HTML | An API call or a build hook |
| Monthly cost | Zero | Seat and API pricing |
| Migration out | It is Markdown in Git | An export and a rewrite |
Two rows carry more weight than the rest. Publishing latency is what non-technical editors feel every day. Broken internal links is what a marketing site bleeds from quietly for months.
The case for MDX, stated precisely
The usual pitch is "it's just files, and it's free". Both true, both beside the point. The real advantage is that content becomes something your type system and your test suite can reason about.
Our post metadata is a typed array, deliberately separate from the prose:
export interface BlogPostMeta {
slug: string;
title: string;
description: string;
date: string;
cluster: BlogCluster;
targetKeyword: string;
readingMinutes: number;
/** Catalog slugs this post funnels to. */
relatedTemplates: string[];
/** Root-level landing routes this post links to. */
relatedSpokes: string[];
tags: string[];
}
Metadata lives apart from prose for a concrete reason: the index grid, the home-page teaser, the sitemap and the RSS feed all need to list posts. If the list came from the articles, every one of those surfaces would compile every article to render a set of titles.
And because relatedTemplates holds real product IDs, a test can assert they exist:
it("relatedTemplates all reference real catalog products", () => {
for (const post of blogPosts) {
for (const slug of post.relatedTemplates) {
expect(getProduct(slug), `${post.slug} → ${slug}`).toBeDefined();
}
}
});
Retire a product and CI fails, naming the post that links to it. In a CMS, that same link is a string in a rich-text field, and it becomes a 404 the day the product page comes down — discovered whenever someone next reads Search Console.
That is the argument. Not "free", but "your content is in the same correctness system as your code."
Three files that must agree
The cost of that design, stated honestly: a post is three files, not one.
src/data/blog.ts— the metadata entry.src/content/blog/<slug>.mdx— the prose.- A loader entry in
src/lib/blog.tspairing them.
That is one more moving part than a CMS, and the mitigation is to make drift a build failure rather than a runtime surprise:
it("every post has a registered MDX loader", () => {
for (const post of blogPosts) {
expect(registeredSlugs, `${post.slug} has no loader`).toContain(post.slug);
}
});
it("has no loader without a matching post entry", () => {
for (const slug of registeredSlugs) {
expect(getPost(slug), `loader ${slug} has no metadata`).toBeDefined();
}
});
The loader map is written out slug by slug on purpose. A dynamic import built from a template string works, but it hands the bundler a wildcard: every file in the directory joins the graph, a typo becomes a runtime error instead of a build error, and nothing type-checks.
The Turbopack trap that costs an afternoon
If you take one implementation detail from this post, take this one. Remark and rehype plugins must be named as strings, not imported:
// next.config.ts — correct
const withMDX = createMDX({
options: {
remarkPlugins: ["remark-gfm"],
rehypePlugins: ["rehype-slug"],
},
});
// Builds cleanly. Silently does nothing.
import remarkGfm from "remark-gfm";
const withMDX = createMDX({
options: { remarkPlugins: [remarkGfm] },
});
Turbopack runs the MDX pipeline in Rust, and config has to be serialized to cross that boundary — a JavaScript function reference cannot make the trip.
How that fails depends on your Next.js version, and it changed. On current Next (16.2.9, re-tested for the Turbopack post) the imported-function form is a hard build error: does not have serializable options. Earlier versions accepted it, built successfully, and silently dropped the plugin — which is the genuinely dangerous outcome, because MDX defaults to CommonMark, which has no table syntax. Without remark-gfm every table row renders as a paragraph of literal pipe characters. You will look at a broken comparison table and go hunting through your CSS.
One more config line worth understanding:
// `.mdx` is NOT in pageExtensions on purpose: posts are imported as modules
// from the typed registry, not routed file-by-file. Adding it would make
// every stray .mdx a public route.
Where a headless CMS is simply better
Four situations, and none of them is a close call:
Your writers are not engineers. A marketer who has to clone a repo to fix a typo will stop fixing typos. This is the decisive case and it outranks every technical argument on this page.
Content changes without a deploy. Pricing copy, a legal page, a campaign banner. Waiting on CI to change a number is the wrong shape.
You need real querying. "Most-read posts from the last thirty days, by category, excluding the ones tagged internal" is a line of CMS query and an afternoon of TypeScript.
Editorial process is the product. Scheduled publishing, draft previews for stakeholders, roles and permissions, translation workflows. You can build these. You should not want to.
There is also a scale threshold: MDX compiles at build time, so build duration grows with post count. At thirty posts this is invisible. At several thousand it is a real constraint, and incremental or on-demand rendering from a CMS starts to win on mechanics rather than taste.
The hybrid, and why we did not take it
A common middle path stores MDX source in a CMS text field: editors get an admin panel, developers keep portable Markdown, and the site fetches at build time.
It is a reasonable compromise that gives up the thing MDX is actually for. Content in a remote field cannot be type-checked, cannot be diffed in review, and cannot have its internal links verified in CI. You keep the syntax and lose the guarantees.
A better hybrid, when the split is real, is by content type rather than by storage: engineering-authored long-form in MDX, marketing-authored campaign pages in a CMS. Two systems, two audiences, no field pretending to be a file.
Choosing, in one pass
Answer these in order and stop at the first clear signal:
- Will non-engineers publish without help? Yes → CMS.
- Must content change without a deploy? Yes → CMS.
- Do you need editorial workflow — scheduling, roles, previews? Yes → CMS.
- Do you need to query content by arbitrary fields? Yes → CMS.
- Is content authored by the same people who ship the site? Yes → MDX.
- Do posts embed live components — demos, charts, calculators? Strongly MDX.
- Is the content set small enough to compile every build? MDX stays comfortable.
For a developer-facing product blog, questions 5 and 6 usually decide it before you reach the others.
Mistakes and how they show up
| Mistake | What happens | Fix |
|---|---|---|
| Imported remark/rehype plugin objects | Plugin silently dropped; tables render as pipes | Name plugins as strings |
| Wildcard dynamic import for posts | Typos become runtime 404s | Explicit slug-to-import map |
.mdx added to pageExtensions | Every stray draft becomes a public route | Import as modules, not routes |
| Metadata inside the MDX front matter | Index and sitemap compile every article | Separate typed registry |
| Internal links as untested strings | Dead links after a product is retired | Test that link targets resolve |
| No metadata route for OG cards | Social previews fall back to a site default | Generate per post — see the guide |
| Choosing MDX for a marketing team | Publishing stalls behind engineering | Pick by author, not by architecture |
| Choosing a CMS for two engineers | Cost and an API call for what a file does | Files, until an editor asks for a login |
Frequently asked questions
Is MDX bad for SEO because it needs a deploy to publish? No. Publishing latency affects your workflow, not your rankings. Compiled MDX is static HTML — the fastest thing you can serve, with no client-side fetch and no hydration cost for the prose. What does affect rankings is churn in URLs and internal links, and MDX in Git makes both auditable in review.
Does MDX let a post ship JavaScript to readers? It can, and that is the feature. A post can embed a live component. Ours mostly do not: the prose compiles to plain HTML, which means articles add no runtime script and require no Content Security Policy change. Reach for a component when the interaction earns the bytes.
How hard is migrating MDX into a CMS later? Easier than the reverse. The prose is Markdown, the metadata is already a structured array that maps cleanly onto CMS fields, and the two are already separate. Migrating out of a CMS means exporting proprietary rich-text JSON and rebuilding every embed.
What about Git-based CMSs? They are the honest version of the hybrid: a web editor that commits Markdown to your repository. You keep files, diffs and CI checks while non-engineers get a login. If your blocker is purely "the writers cannot use Git", this is the smallest change that fixes it.
Do I need remark-gfm if I never write tables?
You will write tables. It also brings strikethrough, task lists and autolinks. It costs one string in the config and removes an entire category of "why does my Markdown not render" confusion.
Templates with the content surface already built
The blog is rarely the hard part of a marketing site — the pages around it are.
ASoc Script markets an AI copywriting product, with an interactive generator hero, a filterable template gallery and a five-question FAQ. ASoc Magnet is a lead-capture SaaS site built around channel insights and capability blocks, the natural destination for content traffic. ASoc Brief is a designer's résumé and portfolio site with a blog view already in the icon-rail navigation.
Browse the full set of Next.js landing page templates or the Tailwind landing page templates. If you are moving an existing blog onto a new stack, the migration guide covers the redirect map and the internal-link diff that decide whether the rankings survive.
