Skip to main content
ASoc
Comparison

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.

The ASoc Team10 min read

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 repoHeadless CMS
Who can publishAnyone who can open a PRAnyone with a login
Publishing latencyA deploy (minutes)Seconds, no deploy
Review workflowCode review, freeWhatever the CMS ships
Type safety on metadataCompile-time, yoursRuntime, generated at best
Broken internal linksCatchable in CIFound in production
Custom components in proseNative — it is JSXEmbeds, shortcodes, or blocks
Structured queryingWhatever you write in TypeScriptA query API you get for free
ImagesYour build pipelineTheir pipeline and CDN
Scheduled publishingCron or a manual mergeBuilt in
LocalizationYou build itUsually built in
Runtime costZero — compiles to HTMLAn API call or a build hook
Monthly costZeroSeat and API pricing
Migration outIt is Markdown in GitAn 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.

  1. src/data/blog.ts — the metadata entry.
  2. src/content/blog/<slug>.mdx — the prose.
  3. A loader entry in src/lib/blog.ts pairing 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:

  1. Will non-engineers publish without help? Yes → CMS.
  2. Must content change without a deploy? Yes → CMS.
  3. Do you need editorial workflow — scheduling, roles, previews? Yes → CMS.
  4. Do you need to query content by arbitrary fields? Yes → CMS.
  5. Is content authored by the same people who ship the site? Yes → MDX.
  6. Do posts embed live components — demos, charts, calculators? Strongly MDX.
  7. 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

MistakeWhat happensFix
Imported remark/rehype plugin objectsPlugin silently dropped; tables render as pipesName plugins as strings
Wildcard dynamic import for postsTypos become runtime 404sExplicit slug-to-import map
.mdx added to pageExtensionsEvery stray draft becomes a public routeImport as modules, not routes
Metadata inside the MDX front matterIndex and sitemap compile every articleSeparate typed registry
Internal links as untested stringsDead links after a product is retiredTest that link targets resolve
No metadata route for OG cardsSocial previews fall back to a site defaultGenerate per post — see the guide
Choosing MDX for a marketing teamPublishing stalls behind engineeringPick by author, not by architecture
Choosing a CMS for two engineersCost and an API call for what a file doesFiles, 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.

Keep reading

Comparison11 min read

Monorepo vs Multi-Repo: 110 Products Across 113 Repositories

Choose by the boundary the customer receives, not by code sharing. The cost is not merging — it is the generated index, and ours already had four bad rows.

Read more
Comparison12 min read

Next.js App Router vs Pages Router: Which to Use in 2026

The App Router is the default for new projects, but the Pages Router is not deprecated. Here is what actually changed and when migrating is not worth it.

Read more
Comparison8 min read

Next.js vs. Angular: A Function Call vs. a DI Container

Angular needs a DI container before a component can fetch anything; a Next.js Server Component just calls a function. Measured from this storefront's 334-page static build.

Read more