How-to

How to Use MDX with Next.js and a Headless CMS

Use MDX with Next.js and a headless CMS: fetch the string, compile it in RSC, pass components through a map, then cache and preview it correctly.

7 min read
Pipeline diagram: a CMS content card feeds a component allowlist gate, which feeds a rendered page, with a revalidation loop back to the start

To use MDX with Next.js and a headless CMS: fetch the MDX string in a Server Component, compile it at request time, then pass your React components in through a components map. That's the whole loop. One part catches people out. MDX from a CMS can't import anything. So the components map is the only way a component reaches the page. Draftbase stores MDX as a plain string in a richText field, and @draftbase/renderer compiles it in RSC. Here's the full path, plus the caching and preview work a "hello world" version skips.

How does MDX work with React in Next.js?

MDX isn't a template language. It compiles to a JavaScript module that exports a React component.

That compile happens in one of two places. At build time, from files in your repo, using @next/mdx. Or at request time, from a string you fetched. A CMS forces the second. The content isn't on disk.

Request-time compiling used to mean shipping a compiler to the browser. Not anymore. React Server Components run the compile on the server. Only the rendered output crosses the wire. Your bundle stays the same size whether a post is 200 words or 4,000.

Why store MDX in a headless CMS instead of the repo?

Files in a repo work fine until a second person needs to publish.

In a repo, every typo fix is a pull request, a review, a merge, and a deploy. Non-developers can't do it. Developers resent doing it. Scheduled publishing becomes a cron job that opens a PR. That's as fragile as it sounds.

A CMS moves that string into a database row. You get draft states, revisions with rollback, and a publish date. No CI involved. The MDX CMS guide goes deeper on the storage model. Whether prose should be MDX at all is settled in MDX vs rich text. If you're still weighing platforms for the whole stack, how to choose a CMS for a Next.js project covers that decision.

Fetching MDX content from a delivery API

Draftbase's delivery API is REST, key-authed, and cached per org. There's no /v1/ prefix.

async function getPost(slug: string) {
  const res = await fetch(
    `https://api.draftbase.co/delivery/entries?templateId=blogPost&search=${slug}`,
    {
      headers: { Authorization: `Bearer ${process.env.DRAFTBASE_KEY}` },
      next: { tags: ["posts", `post:${slug}`] },
    },
  );
  return res.json();
}

The next.tags array is doing quiet work. Tag the fetch now, and later you can clear exactly this post from a webhook. No site rebuild.

Using React components inside MDX

Render with MDXContent. It's a Server Component, so you await it in the page.

import { MDXContent } from "@draftbase/renderer";
import { Callout } from "@/components/callout";
import { PricingTable } from "@/components/pricing-table";

export default async function Page({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  const post = await getPost(slug);

  return (
    <article>
      <h1>{post.fields.title}</h1>
      <MDXContent source={post.fields.content} components={{ Callout, PricingTable }} />
    </article>
  );
}

An editor can now write <Callout type="warning">Deploys are frozen.</Callout> in the CMS and it renders as your component, with your styles.

Client components inside server-rendered MDX

Anything interactive needs "use client" at the top of the component file. Not on the MDX, on the component. The MDX itself compiles on the server either way, and a client component passed through the map hydrates normally.

Props have a limit worth knowing. Values you pass from MDX ride along in the RSC payload. A function won't survive the trip. Pass a string id and look it up on the client.

How do you cache MDX pages and still publish instantly?

Tag the fetch, then invalidate the tag from a CMS webhook. Two moving parts.

// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";

export async function POST(request: Request) {
  const body = await request.json();
  revalidateTag(`post:${body.entry.fields.slug}`);
  return Response.json({ revalidated: true });
}

Point a Draftbase webhook at that route on the entry publish event. An editor hits publish. The webhook fires. One page goes stale, and the next request rebuilds it. No redeploy, no waiting out a TTL.

Check the webhook signature before you trust the body. An open revalidate route is a free cache-flush button for anyone who finds the URL.

The underused angle: MDX from a CMS can't import anything

Here's the detail almost every tutorial skips, because file-based MDX hides it.

MDX in a repo can write import { Chart } from "../components/chart" at the top. MDX from a CMS can't. @mdx-js/mdx compiles runtime strings in function-body output format. That format throws on import statements unless you set useDynamicImport. Draftbase's renderer doesn't set it.

That sounds like a limitation. It's the security model.

With imports off, the components prop is the only door in. A tag you didn't pass renders as plain text, or hits the error boundary. Nothing else gets through. CMS content is remote input. An allowlist you write in code is the right boundary for it.

So treat the map as a decision, not a config line. Write it out by hand. Don't spread a barrel file of every component you own into it. That quietly re-opens the door you just shut.

Previewing drafts when the delivery API only serves published entries

Draftbase's delivery API returns published content only. Edit a published entry and the API still returns the last published revision, not your pending edit. A plain fetch can never show a draft.

Next.js Draft Mode fixes the caching half. The official guide has the full flow, and the API is async as of Next.js 16:

import { draftMode } from "next/headers";

export async function GET(request: Request) {
  const draft = await draftMode();
  draft.enable();
  redirect(post.slug);
}

draft.enable() sets a cookie named __prerender_bypass. Requests carrying it skip the fetch cache, 'use cache' scopes, and the ISR response cache.

But skipping the cache isn't enough alone. Your fetch still has to read from somewhere that holds the draft. Branch on isEnabled. When it's on, call the management API with a management token. Same page, same map, different source.

How do you render CMS MDX outside RSC?

Not every app has Server Components. Pages Router, a client-side dashboard, React Native, Remix. All of them still need to render that string.

Use compileMDX directly. MDXContent is just an RSC wrapper around it, and await in a component body only works as an RSC.

import { compileMDX } from "@draftbase/renderer";

const compiled = await compileMDX(post.fields.content);
if (compiled.ok) {
  const { Content } = compiled;
  // render <Content components={{ Callout }} /> from state
}

Call it in your own data loader or effect, hold the result in state, then render Content yourself. The components prop works the same way.

Two things change once you leave the server. First, the compiler runs in the browser, so it lands in your bundle. That's a real cost, and on a marketing site it's the wrong trade. Compile on the server and cache the output where you can.

Second, React Native has no intrinsic div or p tags. Nothing renders unless you map the standard markdown elements yourself. Pass overrides for p, h1, a, and img in the same map, alongside your custom components. Set wrapperTag to a View and errorTag to a Text while you're there.

What goes wrong in production?

Four failures, all cheap to prevent.

A stray < or { in prose breaks the compile. Draftbase's MDXContent fails soft here. It logs the error and renders the raw string as plain text. Ugly text beats a 500.

Renaming a component prop quietly breaks old posts. Nothing errors. The prop is just ignored. Every post written before the rename loses a feature. Typed props on registered MDX components catch this at build time.

Forgetting the tag on a fetch means content updates never land. The webhook fires, the tag matches nothing, the page serves stale until its TTL expires.

And a huge MDX string compiles on every uncached request. Compilation isn't free. Keep the cache tags tight so the compile happens once per publish, not once per visitor.

Ship it

The pieces are small. A tagged fetch, a Server Component that compiles the string, a components map you write out by hand, and a webhook that invalidates one tag.

Draftbase fits this shape. It stores MDX as a plain string, not a vendor tree, so @draftbase/renderer compiles it with no conversion step. You also get revisions with rollback for the day a component change breaks 40 pages. The Hobby plan is free. The Startup plan is $49/mo, listed on the headless CMS pricing page. Start with one template, one tagged fetch, and one component in the map.

How to

  1. 1
    Create a template with a richText field

    In your CMS, define a template whose fields include a richText field for the MDX body, plus typed fields for title, slug, and any structured metadata.

  2. 2
    Fetch the MDX string with a cache tag

    Call the delivery API from a Server Component with a Bearer key, and attach a Next.js cache tag per entry, for example next: { tags: ['post:my-slug'] }.

  3. 3
    Render with MDXContent and a components map

    Import MDXContent from @draftbase/renderer, pass the entry's MDX string as source, and pass only the components you want reachable in the components prop.

  4. 4
    Mark interactive components as client components

    Add 'use client' to the top of any component that uses state or event handlers. The MDX still compiles on the server, and the client component hydrates normally.

  5. 5
    Wire a webhook to revalidateTag

    Add a POST route handler that verifies the webhook signature and calls revalidateTag with the entry's slug, then point a CMS webhook at it on the entry publish event.

  6. 6
    Add draft preview with Draft Mode

    Create a GET route handler that checks a shared secret, awaits draftMode(), calls draft.enable(), and redirects. Branch your fetch on isEnabled to read drafts from the management API.

Ship content that's built to be found

Draftbase generates schema, structured data, and a fast MDX editor for every post.

Frequently asked questions

How do I use MDX with Next.js and a headless CMS?

Fetch the MDX string from the CMS in a Server Component, compile it at request time, and pass your React components in through a components map. No build step needed.

Can you use React components inside MDX stored in a CMS?

Yes, through the components prop. MDX from a CMS cannot use import statements, so the map you pass is the only way a component reaches the page.

Does compiling MDX at request time bloat the JavaScript bundle?

Not in the App Router. React Server Components run the compile on the server and send only rendered output, so bundle size stays flat regardless of post length.

How do I publish MDX changes without redeploying the site?

Tag each fetch with next.tags, then call revalidateTag from a route handler triggered by a CMS publish webhook. One page goes stale and rebuilds on the next request.

Why can't I preview drafts through the delivery API?

Draftbase's delivery API serves published content only, returning the last published revision for edited entries. Use Next.js Draft Mode plus the management API for previews.

Related reading

Go deeper on MDX Editor