Type something to search...
Next.js Using markdown and MDX

Next.js Using markdown and MDX

Markdown is the easiest way to write structured content without touching HTML, and if you've ever written a README, a changelog, or a blog post in a .md file, you already know the syntax. What you might not know is that Next.js can compile that same plain-text format directly into pages, routes, and even Server Components, without you ever converting it to JSX by hand. And when plain markdown isn't expressive enough, MDX lets you drop real React components straight into the middle of your prose.

This matters more than it sounds like on the surface. A lot of teams end up building a small home-grown pipeline for this: read a markdown file with fs, parse it with some combination of gray-matter and a markdown-to-HTML library, then dangerouslySetInnerHTML the result into a page. It works, but it's brittle, it can't render interactive components inside the content, and it usually needs extra plumbing to support syntax highlighting or custom typography. @next/mdx replaces all of that with a first-class compiler integration, and it plays directly into the file-based routing you already use for everything else in the App Router.

This article walks through setting it up from scratch: installing the right packages, wiring next.config.mjs, rendering MDX both as routes and as imports, styling the output so it doesn't look like unstyled browser defaults, working with frontmatter, and where the Rust-based compiler currently stands. I'll also flag a few things the docs mention only in passing that are worth understanding properly before you commit to this for a content-heavy site.

Markdown vs. MDX: what's actually different

Markdown is a text-formatting convention. You write **bold** and get <strong>bold</strong>; you write a - list and get a <ul>. It's static — there's no way to embed a live component, a chart, or a piece of interactive UI inside a .md file, because markdown has no concept of a component.

MDX is a superset of markdown that lets JSX exist directly in the document. You can still write # Heading and **bold** exactly as before, but you can also drop in <MyComponent /> in the middle of a paragraph and have it render as a real, hydrated React component. Under the hood, @next/mdx compiles your .mdx file into a React component — this is why you can import it just like any other component, and why it can run happily as a Server Component (the default rendering mode in the App Router) as long as the content and any components it references don't need client-side interactivity.

The distinction matters when you're deciding which extension to use for a given file. If a piece of content genuinely never needs interactive elements — a terms-of-service page, most blog posts, a changelog — plain .md is simpler and you can stop reading about JSX entirely. If you want to embed a live code sandbox, a custom callout component, or a chart inside your prose, you need .mdx.

Step 1: Install the packages

Everything starts with four packages: the Next.js plugin itself, the webpack/Turbopack loader, the MDX React runtime, and the TypeScript types.

npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx

@next/mdx is the piece that actually wires MDX compilation into the Next.js build pipeline. @mdx-js/loader is the underlying loader that @next/mdx orchestrates. @mdx-js/react provides the runtime context that lets custom component overrides flow down to nested MDX content. @types/mdx gives you accurate types for .mdx imports if you're using TypeScript — without it, TypeScript will complain that it doesn't know how to resolve a .mdx module.

You don't need to install remark or rehype yourself unless you plan to add custom transform plugins later — @next/mdx bundles the baseline pipeline for you.

Step 2: Configure next.config.mjs

This is the step that trips people up most, because the config file has to become an ES module (.mjs or .ts) for the plugin wrapping pattern to work cleanly, even if the rest of your project is otherwise CommonJS.

// next.config.mjs
import createMDX from "@next/mdx";

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};

const withMDX = createMDX({
  // Add markdown/MDX plugins here, as desired
});

export default withMDX(nextConfig);

Two things are happening here. First, pageExtensions tells Next.js's router that .md and .mdx files are valid route file extensions, alongside the usual .js/.tsx. Without this line, a page.mdx file simply won't register as a route — Next.js will silently ignore it, which is a confusing failure mode if you don't know to look for it.

Second, createMDX() returns a function that wraps your existing Next.js config and layers in the MDX webpack/Turbopack loader. This is the same "higher-order config" pattern you'll recognize from next-pwa, @next/bundle-analyzer, and similar plugins — you're composing config functions, not merging plain objects.

If you need .md files (not just .mdx) to go through the same compiler, you have to opt in explicitly:

const withMDX = createMDX({
  extension: /\.(md|mdx)$/,
});

By default @next/mdx only touches .mdx. This is easy to miss if your existing content is all plain .md and you're wondering why nothing is rendering.

Step 3: Add the required mdx-components.tsx file

This is the one step that's genuinely non-optional in the App Router — skip it, and MDX pages will fail to build. Create mdx-components.tsx at the same level as your app directory (or inside src/ if that's where your app folder lives):

// mdx-components.tsx
import type { MDXComponents } from "mdx/types";

const components: MDXComponents = {};

export function useMDXComponents(): MDXComponents {
  return components;
}

Even with an empty components object, this file has to exist and export useMDXComponents. The App Router uses it as the hook point for injecting custom component overrides into every MDX file in your project — Server Components don't have the equivalent of the Pages Router's global _app.js to do this kind of thing implicitly, so this file is effectively that missing piece for MDX specifically.

Step 4: Render MDX with file-based routing

Once the plugin and the components file are in place, an .mdx file works exactly like any other page.tsx — drop it where a route needs to exist:

app/
└── mdx-page/
    └── page.mdx
mdx-components.tsx
import { MyComponent } from "@/components/my-component";

# Welcome to my MDX page!

This is some **bold** and _italic_ text.

- One
- Two
- Three

Here's a live component embedded in the prose:

<MyComponent />

Navigate to /mdx-page and Next.js renders it just like a normal route — including generateMetadata support if you export it from the file, since MDX pages are treated as first-class App Router pages, not a bolt-on content type.

Step 5: Render MDX by importing it

The alternative — and the one I reach for more often on real projects — is to keep your MDX content out of the routing tree entirely and import it into a .tsx page that controls the surrounding layout:

app/
└── mdx-page/
    └── page.tsx
markdown/
└── welcome.mdx
mdx-components.tsx
// app/mdx-page/page.tsx
import Welcome from "@/markdown/welcome.mdx";

export default function Page() {
  return <Welcome />;
}

This pattern is what you want the moment your content needs to sit inside a shared page shell — a sidebar, breadcrumbs, a "last updated" banner — without duplicating that shell logic per content file. It also keeps your markdown/ directory conceptually separate from your route tree, which scales much better once you have dozens or hundreds of content files.

Step 6: Dynamic imports for content collections

For a blog or docs section with many entries, you don't want a hardcoded import per post. Combine a dynamic route segment with a dynamic import():

// app/blog/[slug]/page.tsx
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const { default: Post } = await import(`@/content/${slug}.mdx`);

  return <Post />;
}

export function generateStaticParams() {
  return [{ slug: "welcome" }, { slug: "about" }];
}

export const dynamicParams = false;

generateStaticParams prerenders exactly the slugs you list, and dynamicParams = false means any slug outside that list resolves to a 404 instead of Next.js trying (and failing) to render it on demand. This combination is worth understanding on its own — it's the difference between "prerender these specific posts and 404 everything else" versus "prerender these and lazily render anything new at request time." For a content set that's fully known at build time, dynamicParams = false is a nice guardrail against silently serving a broken page for a typo'd slug.

One easy-to-miss detail: the .mdx extension in the dynamic import isn't optional cosmetic sugar — you have to include it, unlike normal component imports where the bundler resolves the extension for you. If you're using module path aliases (@/content/...), that's a convenience for the path prefix, not a substitute for the extension.

Step 7: Style the output — global overrides

Raw markdown compiles to plain, unstyled HTML elements: an <h2> is just a browser-default <h2>, a list is a browser-default <ul>. If you don't style these, your MDX content will look noticeably different from the rest of your design system. The cleanest fix is overriding the default element-to-component mapping globally, in the same mdx-components.tsx file from Step 3:

// mdx-components.tsx
import type { MDXComponents } from "mdx/types";
import Image, { ImageProps } from "next/image";

const components = {
  h1: ({ children }) => (
    <h1 style={{ color: "red", fontSize: "48px" }}>{children}</h1>
  ),
  img: (props) => (
    <Image
      sizes="100vw"
      style={{ width: "100%", height: "auto" }}
      {...(props as ImageProps)}
    />
  ),
} satisfies MDXComponents;

export function useMDXComponents(): MDXComponents {
  return components;
}

Notice the img override — this is genuinely useful beyond styling. Any bare ![alt](src) markdown image, in any MDX file across your whole app, now automatically routes through next/image, picking up lazy loading and automatic optimization without every content author needing to remember to use the Image component explicitly. This is one of the more underrated reasons to reach for MDX over plain markdown-to-HTML pipelines: you get to intercept the primitive HTML elements and upgrade them app-wide, in one place.

Step 8: Style the output — local overrides and shared layouts

Global overrides apply everywhere, which is sometimes too broad. For a one-off page that needs different heading styling, pass local overrides through the components prop when you render an imported MDX file:

// app/mdx-page/page.tsx
import Welcome from "@/markdown/welcome.mdx";

function CustomH1({ children }) {
  return <h1 style={{ color: "blue", fontSize: "100px" }}>{children}</h1>;
}

export default function Page() {
  return <Welcome components={{ h1: CustomH1 }} />;
}

Local overrides merge with (and take precedence over) the global ones, so you're never forced to redefine every element just to change one.

For a whole section of MDX pages that should share the same visual treatment, use a regular App Router layout.tsx at the appropriate segment:

// app/mdx-page/layout.tsx
export default function MdxLayout({ children }: { children: React.ReactNode }) {
  return <div style={{ color: "blue" }}>{children}</div>;
}

If you're already using Tailwind, the @tailwindcss/typography plugin is the pragmatic option here rather than hand-rolling heading/paragraph/list styles — it gives you a prose class you can drop on the layout wrapper and get sane, readable defaults for arbitrary markdown content immediately:

export default function MdxLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="prose dark:prose-invert prose-headings:font-semibold">
      {children}
    </div>
  );
}

That said, this project is on Tailwind CSS 4 — if you're on v4, install @tailwindcss/typography the v4 way (via @plugin in your CSS entry point rather than tailwind.config.js, since v4 moved config into CSS-first configuration). Don't copy v3-era setup instructions blindly.

Frontmatter: the gap the plugin doesn't fill

Here's something the docs are upfront about but that surprises people anyway: @next/mdx does not parse frontmatter by default. If you write this expecting title and date to be extracted automatically:

---
title: My Post
date: 2027-01-01
---

# Hello

...nothing happens with that YAML block out of the box. @next/mdx's answer to metadata is instead to let you export plain JavaScript values from the MDX file itself:

export const metadata = {
  author: "John Doe",
};

# Blog post
import BlogPost, { metadata } from "@/content/blog-post.mdx";

export default function Page() {
  console.log(metadata); // { author: 'John Doe' }
  return <BlogPost />;
}

This is a meaningfully different mental model from the YAML-frontmatter-plus-gray-matter pattern a lot of static site generators use, and it's worth internalizing before you architect a content collection around it. If you specifically want YAML frontmatter syntax to keep working, you'd add remark-frontmatter and remark-mdx-frontmatter to the remark pipeline — but at that point, ask whether you actually need MDX's JSX-in-content power for that content type, or whether plain markdown parsed with something like gray-matter (which is exactly what this project's own blog pipeline uses, per its package.json) is simpler for content that's ultimately just prose with structured metadata, no embedded components.

Adding remark and rehype plugins

The compiler is built on the remark (markdown) and rehype (HTML) ecosystems, and you can extend either. A common one is remark-gfm for GitHub-Flavored Markdown — tables, strikethrough, autolinked URLs, task lists:

// next.config.mjs
import remarkGfm from "remark-gfm";
import createMDX from "@next/mdx";

const nextConfig = {
  pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};

const withMDX = createMDX({
  options: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [],
  },
});

export default withMDX(nextConfig);

Because the remark/rehype ecosystem is ESM-only, this is the other reason your config file has to be next.config.mjs (or .ts) rather than the classic CommonJS next.config.js — a plain require() can't consume these packages.

If you're on Turbopack, there's a wrinkle worth knowing about upfront: plugins that need to be configured with a JavaScript function (rather than a plain serializable options object) currently can't be passed through, because Turbopack's Rust core can't accept an opaque JS closure across that boundary. The workaround is to reference plugins by package name string instead of importing and passing the function directly:

const withMDX = createMDX({
  options: {
    remarkPlugins: ["remark-gfm", ["remark-toc", { heading: "The Table" }]],
    rehypePlugins: ["rehype-slug", ["rehype-katex", { strict: true }]],
  },
});

This is exactly the kind of detail that only bites you the moment you switch dev servers from webpack to Turbopack (or vice versa) and a plugin that worked fine suddenly silently stops applying. If your MDX pipeline behaves differently between next dev and next dev --turbopack, this is the first thing to check.

What's actually happening under the hood

You don't need this to use MDX day-to-day, but it's worth understanding once, because it demystifies a lot of the plugin-configuration surface area. React has no native concept of markdown — the plaintext has to be parsed into an abstract syntax tree, transformed, and serialized into something React (or plain HTML) can render. That's the job of remark (the markdown-focused half of the toolchain) and rehype (the HTML-focused half):

import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeSanitize from "rehype-sanitize";
import rehypeStringify from "rehype-stringify";

const file = await unified()
  .use(remarkParse) // text -> markdown AST
  .use(remarkRehype) // markdown AST -> HTML AST
  .use(rehypeSanitize) // sanitize the HTML AST
  .use(rehypeStringify) // HTML AST -> serialized HTML string
  .process("Hello, Next.js!");

console.log(String(file)); // <p>Hello, Next.js!</p>

@next/mdx runs an equivalent pipeline for you automatically, which is why day-to-day you never call unified() yourself. But once you start reaching for plugins like remark-toc (auto table of contents) or rehype-pretty-code (syntax highlighting with themes), you're plugging directly into this same AST pipeline, and understanding it that way makes plugin documentation far less mysterious.

The Rust-based compiler: not ready yet

Next.js also ships an experimental Rust-based MDX compiler (mdxRs), toggled via next.config.js:

module.exports = withMDX({
  experimental: {
    mdxRs: true,
  },
});

The docs are explicit that this is not recommended for production. Treat it as something to try in a side branch if you're chasing build-time performance on a very large content set, not something to reach for on a client project with a deadline. If you do experiment with it, watch for plugin compatibility issues — the same JS-function-vs-serializable-options constraint that affects Turbopack applies here too, since the Rust compiler can't invoke arbitrary JavaScript transform functions either.

Common mistakes worth calling out explicitly

Forgetting pageExtensions. An .mdx file that isn't picked up as a route almost always traces back to a missing or overwritten pageExtensions array. If you have other plugins that also modify next.config, double check they aren't clobbering this array instead of merging into it.

Assuming .md "just works" once @next/mdx is installed. It doesn't, unless you set the extension option to include .md. A lot of confusion here comes from people renaming existing .md content to test the integration, without realizing the plugin's default only watches .mdx.

Skipping mdx-components.tsx. This is the one hard requirement in the App Router specifically — the Pages Router doesn't need it the same way, so anyone migrating router styles can get tripped up here.

Expecting YAML frontmatter to "just work." As covered above, it doesn't by default. If your content pipeline was designed around frontmatter conventions from something like Gatsby or Astro, budget time to either add the remark frontmatter plugins or restructure metadata as MDX exports.

Not deciding early whether you even need MDX. If none of your content embeds live components, plain markdown parsed however you like (including outside of @next/mdx entirely, the way this project's own blog does) is simpler, has fewer moving parts, and avoids JSX-in-content security considerations altogether. MDX earns its complexity when you actually need interactivity inside prose — not by default.

Key Takeaways

ScenarioWhat to do
Content has zero interactive componentsConsider plain markdown outside @next/mdx entirely
Content needs embedded React componentsUse MDX with @next/mdx
Route should render straight from a fileFile-based routing (page.mdx)
Route needs a custom shell/layout around contentImport the .mdx file into a .tsx page
Large, dynamic content collectionDynamic route segment + dynamic import() + generateStaticParams
Styling all markdown output app-wideGlobal overrides in mdx-components.tsx
Styling one page differentlyLocal components prop override
Need YAML frontmatter specificallyAdd remark-frontmatter + remark-mdx-frontmatter, or use MDX exports instead
Using Turbopack with remark/rehype pluginsReference plugins by package name string, not imported function

MDX is one of those features that looks like a documentation-writing nicety until you actually need it — the moment you want a live code example, an interactive chart, or a reusable callout component sitting inside otherwise-static prose, hand-rolled markdown pipelines start to strain, and @next/mdx's tight integration with file-based routing and Server Components stops feeling like overhead and starts feeling like the obvious way to do it.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper