Write Book Nook pages and news posts in MDX with @next/mdx, the required mdx-components.js file, pageExtensions and Turbopack-friendly remark plugins.

Bishal Biswas
Self Learner
Why write pages in MDX
Some pages are mostly words: an About page, shipping information, a news post about the summer sale. Writing those in JSX is clumsy. Every paragraph needs a <p>, every bold word a <strong>, and one missing </p> breaks the page.
Markdown is a simple way to write formatted text with plain characters:
We are a **tiny** bookshop. Read the [Next.js docs](https://nextjs.org).
becomes
<p>We are a <strong>tiny</strong> bookshop. Read the <a href="https://nextjs.org">Next.js docs</a>.</p>
MDX is Markdown that can also contain JSX. You write normal Markdown, and when you need something interactive or data-driven, you drop a React component right into the text. It is like a word processor document where some paragraphs are live widgets.
In this lesson you will:
- set up
@next/mdxwith the requiredmdx-components.jsfile - turn the Book Nook About page into
page.mdx - import MDX files as components to build a small news section
- style the output and add Markdown plugins that work with Turbopack
Step 1: install the packages
In the Book Nook folder, run:
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
@next/mdxconnects MDX to Next.js.@mdx-js/loaderand@mdx-js/reactdo the actual compiling of MDX into React components.@types/mdxgives editors type information about.mdximports (useful even in a JavaScript project, because VS Code uses it for suggestions).
Step 2: update next.config.mjs
Here is Book Nook’s config so far: what create-next-app made, plus the condition that keeps Tailwind away from CSS Modules (from Global CSS and CSS Modules):
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
turbopack: {
rules: {
"*.css": {
condition: { not: { path: "*.module.css" } },
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
export default nextConfig;
Wrap it with the MDX plugin and add pageExtensions:
// next.config.mjs
import createMDX from '@next/mdx';
/** @type {import('next').NextConfig} */
const nextConfig = {
cacheComponents: true,
partialPrefetching: true,
// Let page.mdx files act as pages, next to the usual ones
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
turbopack: {
rules: {
"*.css": {
condition: { not: { path: "*.module.css" } },
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
const withMDX = createMDX({
// Markdown plugins go here (Step 7)
});
export default withMDX(nextConfig);
Two changes:
pageExtensionsis the list of file endings Next.js treats as routes. By default it does not includemdx, soapp/about/page.mdxwould be ignored. Keepjsandjsxin the list, or your existing pages stop working!createMDX()returns a function,withMDX, that adds MDX support to your config. Your own settings (Cache Components, the Tailwind rule and itscondition) are kept, and CSS Modules keep working.
Restart npm run dev after changing next.config.mjs. The config is only read at start-up.
Step 3: create mdx-components.js (required)
Create mdx-components.js in the project root, next to app and package.json (not inside app):
// mdx-components.js
const components = {};
export function useMDXComponents() {
return components;
}
This file is required. @next/mdx with the App Router does not work without it. It must export a function named useMDXComponents that takes no arguments and returns an object. For now the object is empty; in Step 6 you will use it to style every MDX page at once.
If you forget it, the error is confusing, so remember what it looks like:
TypeError: b.default.createContext is not a function
at module evaluation (content\welcome.mdx.tsx:2:1)
> 2 | import {useMDXComponents as _provideComponents} from "next-mdx-import-source-file";
The words useMDXComponents and next-mdx-import-source-file are the clue: Next.js could not find your mdx-components.js.
Step 4: an About page written in MDX
Delete app/about/page.js (a folder cannot have two page files) and create app/about/page.mdx:
import BookCount from '@/components/BookCount'
export const metadata = {
title: 'About us',
}
# About Book Nook
Book Nook started as a **single shelf** in a corner cafe. Today we ship
hand-picked books to readers everywhere.
## What we believe
- Every reader deserves a good recommendation.
- Small shops can still be quick and friendly.
- A book is a gift, so we wrap it like one.
Browse the **Books** page, or come and say hello.
Visit /about. The Markdown became headings, a list and bold text, and the tab title is About us | Book Nook. Three things in this file are not plain Markdown:
import BookCount from ...brings in a component, just like in a.jsfile.export const metadata = ...works exactly as in a normal page, becausepage.mdxis a normal page. See Page Titles, Descriptions and Metadata.<BookCount />renders the component in the middle of the text.
BookCount can even be an async Server Component that reads data, because MDX pages are Server Components by default:
// components/BookCount.js
import { getBooks } from '@/lib/books';
export default async function BookCount() {
const books = await getBooks();
return <p className="mt-4 font-semibold">We currently stock {books.length} books.</p>;
}
The page shows “We currently stock 4 books.” and the number updates when you add books. Interactive Client Components (with 'use client') work in MDX too.
Note: MDX is stricter than plain Markdown in a few places, because it is also JSX. A
<or{in normal text is read as the start of JSX or an expression. Write<or put the character in backticks, like`{`.
Step 5: import MDX files as components
A page.mdx is great for one page. For a news section with many posts, it is tidier to keep the posts in their own folder and have one page that shows any of them. An imported .mdx file is simply a React component.
Put posts in a content folder at the project root:
book-nook/
├── app/
├── content/
│ ├── welcome.mdx
│ └── summer-sale.mdx
└── mdx-components.js
export const metadata = {
title: 'Summer sale',
date: '2026-07-01',
}
# Summer sale
Every **classic** is half price this week. Perfect for the beach.
That is content/summer-sale.mdx. Now one dynamic route shows any post:
// app/news/[slug]/page.js
import { notFound } from 'next/navigation';
const posts = ['welcome', 'summer-sale'];
export function generateStaticParams() {
return posts.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }) {
const { slug } = await params;
if (!posts.includes(slug)) return {};
const { metadata } = await import(`@/content/${slug}.mdx`);
return { title: metadata?.title ?? slug };
}
export default async function NewsPost({ params }) {
const { slug } = await params;
if (!posts.includes(slug)) notFound();
const { default: Post } = await import(`@/content/${slug}.mdx`);
return (
<article className="p-8">
<Post />
</article>
);
}
await import(...)loads the MDX file. Its default export is the content as a component (here calledPost), and any other exports, likemetadata, come along too.- Keep the
.mdxextension in the import path. The@/content/start is fixed text, so the bundler knows which folder to include. generateStaticParamsprerenders every post at build time. The build output shows○ /news/welcomeand○ /news/summer-sale.- The
posts.includes(slug)check turns unknown slugs like/news/nopeinto the not-found page instead of a crash.
Older tutorials (and the MDX guide) add export const dynamicParams = false here. Do not do that in Book Nook: with Cache Components on, it fails the build with “Route segment config “dynamicParams” is not compatible with nextConfig.cacheComponents“. The includes check does the same job.
To import one file into a normal page, you do not even need await:
// app/page.js
import Welcome from '@/content/welcome.mdx';
export default function HomePage() {
return (
<main className="p-8">
<Welcome />
</main>
);
}
Good to know:
@next/mdxdoes not read YAML frontmatter (the---block at the top of many Markdown files). Useexport const metadata = { ... }as shown, or add a plugin likeremark-frontmatterif you have existing files to support.
Step 6: style every MDX page
Markdown produces plain HTML elements: h1, h2, p, ul, a. Tailwind removes the browser’s default styles from those elements, so your About page looks rather flat. You have two good options.
Option A: map elements to your own components
This is what mdx-components.js is for. Every key replaces an HTML element in all MDX content:
// mdx-components.js
import Link from 'next/link';
const components = {
h1: ({ children }) => <h1 className="mb-4 text-3xl font-bold text-amber-900">{children}</h1>,
h2: ({ children }) => <h2 className="mt-8 mb-2 text-2xl font-semibold">{children}</h2>,
p: ({ children }) => <p className="mb-4 leading-7">{children}</p>,
ul: ({ children }) => <ul className="mb-4 list-disc pl-6">{children}</ul>,
a: ({ href, children }) => (
<Link href={href} className="text-amber-800 underline">
{children}
</Link>
),
};
export function useMDXComponents() {
return components;
}
Now every # Heading uses your style, and every Markdown link uses next/link for fast client-side navigation.
To change styles for a single file only, pass a components prop when you render an imported MDX file. These override the global ones:
// app/news/[slug]/page.js (inside the returned JSX)
<Post components={{ h1: ({ children }) => <h1 className="text-5xl">{children}</h1> }} />
Option B: Tailwind’s typography plugin
The @tailwindcss/typography plugin adds a prose class that styles all the HTML inside it nicely. Install it:
npm install @tailwindcss/typography
Turn it on in your CSS:
/* app/globals.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";
Then wrap the MDX pages in a layout with prose:
// app/about/layout.js
export default function AboutLayout({ children }) {
return <article className="prose prose-stone mx-auto p-8">{children}</article>;
}
A layout.js next to a page.mdx works just like for any other page. See Styling with Tailwind CSS for more about Tailwind.
Step 7: Markdown plugins with Turbopack
MDX can be extended with remark plugins (which change the Markdown) and rehype plugins (which change the resulting HTML). A popular one is remark-gfm, which adds GitHub-style tables, task lists and strikethrough.
npm install remark-gfm
Turbopack, the default bundler in Next.js 16, is written in Rust, and it cannot receive JavaScript functions from your config. So with Turbopack you name plugins as strings, not imports:
// next.config.mjs
const withMDX = createMDX({
options: {
remarkPlugins: ['remark-gfm'],
rehypePlugins: [],
},
});
A plugin with options is written as a pair of the name and an options object, for example ['remark-toc', { heading: 'Contents' }]. Options must be plain data (strings, numbers, booleans, objects). A plugin that needs a function in its options cannot be used with Turbopack yet.
Now this works in any MDX file:
| Book | Price |
| ---- | ----- |
| Dune | 14.50 |
- [x] Order wrapping paper
- [ ] Restock The Hobbit
Common mistakes
The .mdx page gives a 404
Check pageExtensions in next.config.mjs includes 'mdx', and restart the dev server after editing the config.
Every other page suddenly 404s
You wrote pageExtensions: ['md', 'mdx']. That replaces the default list, so .js pages stop being routes. Keep 'js' and 'jsx' in it.
“createContext is not a function” when rendering MDX
mdx-components.js is missing or in the wrong folder. It belongs in the project root (or in src/ if you use a src folder), not inside app, and must export useMDXComponents.
Passing a plugin as an import
import remarkGfm from 'remark-gfm';
// remarkPlugins: [remarkGfm] does not work with Turbopack
Use the plugin’s package name as a string: remarkPlugins: ['remark-gfm'].
Curly braces or angle brackets break the page
MDX reads { and < as code. Escape them or put them in backticks.
Quick recap
- MDX is Markdown that can contain JSX components and
import/exportlines. - Install
@next/mdx @mdx-js/loader @mdx-js/react @types/mdx, wrap the config withcreateMDX(), and add'mdx'topageExtensions(keeping'js'and'jsx'). mdx-components.jsin the project root is required and exportsuseMDXComponents(). Use it to style elements globally.app/about/page.mdxis a real page: it can exportmetadataand render Server Components.- Imported
.mdxfiles are components. Useawait import()withgenerateStaticParamsfor a folder of posts, and skipdynamicParamswhen Cache Components is on. - With Turbopack, list remark and rehype plugins as strings with serializable options.
// next.config.mjs: Book Nook with MDX support (restart the dev server after editing)
import createMDX from '@next/mdx';
/** @type {import('next').NextConfig} */
const nextConfig = {
cacheComponents: true,
partialPrefetching: true,
// page.mdx files become routes; keep js and jsx so normal pages still work
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
turbopack: {
rules: {
"*.css": {
condition: { not: { path: "*.module.css" } },
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
const withMDX = createMDX({
options: {
// Turbopack needs plugin names as strings, not imported functions
remarkPlugins: ['remark-gfm'],
rehypePlugins: [],
},
});
export default withMDX(nextConfig);
// --- next file: mdx-components.js (required, in the project root)
// import Link from 'next/link';
// const components = {
// h1: ({ children }) => <h1 className="mb-4 text-3xl font-bold">{children}</h1>,
// a: ({ href, children }) => <Link href={href} className="underline">{children}</Link>,
// };
// export function useMDXComponents() {
// return components;
// }
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready