MDX with @next/mdx and custom component mapping, remote MDX from a CMS, the View Transitions API with the viewTransition config, and when MDX is the right choice vs a headless CMS.
P-12 — MDX, View Transitions, and Content-Rich Application Patterns
Who this is for: Engineers building documentation sites, blogs, changelogs, or any application where content is a first-class concern — not just a few marketing pages, but a real content system. This module covers MDX from zero to production, the new View Transitions API in Next.js 15, and the architectural decisions that determine whether your content system scales or collapses under its own weight.
MDX vs a Headless CMS — Getting the Decision Right
MDX (Markdown with embedded JSX) and a headless CMS like Contentful, Sanity, or Notion are not competing solutions to the same problem. They solve different problems, and confusing them leads to regret.
MDX is the right choice when the people creating content are developers, the content lives in your repository, versioning alongside code is a feature rather than a burden, and the content sometimes needs to embed custom components or interactive elements that a CMS rich text editor can't express. Documentation sites (Next.js docs, Radix docs, Tailwind docs), changelogs, and developer-facing tutorials are natural MDX fits.
A headless CMS is the right choice when non-technical people need to author or edit content, when content needs to go through a review workflow, when content updates should be deployable without a code push, or when the same content is consumed by multiple surfaces (web, mobile, email). Marketing pages, blog posts authored by a content team, product descriptions, and landing pages belong here.
The failure mode is using MDX for content that non-developers will need to edit, or using a headless CMS for documentation that developers want to version-control. Both feel fine initially and become painful quickly. Make the choice based on who writes the content, not on what you find easier to implement.
Setting Up @next/mdx
The official @next/mdx package integrates MDX into the Next.js build pipeline. Install the dependencies and configure the plugin:
bashnpm install @next/mdx @mdx-js/loader @mdx-js/react npm install --save-dev @types/mdx
ts// next.config.ts import createMDX from '@next/mdx' const withMDX = createMDX({ options: { remarkPlugins: [], rehypePlugins: [], }, }) export default withMDX({ pageExtensions: ['ts', 'tsx', 'md', 'mdx'], })
Adding 'md' and 'mdx' to pageExtensions means you can place .mdx files directly in your app/ directory and Next.js treats them as pages. An app/docs/getting-started.mdx file becomes the /docs/getting-started route. No routing boilerplate required.
mdx-components.tsx — Replacing HTML Elements
The mdx-components.tsx file at the root of your project (or src/) is how you map MDX's generated HTML elements to your own components. This is where the composition model of MDX really shines:
tsx// mdx-components.tsx import type { MDXComponents } from 'mdx/types' import Image, { type ImageProps } from 'next/image' import Link from 'next/link' export function useMDXComponents(components: MDXComponents): MDXComponents { return { h2: ({ children, id }) => ( <h2 id={id} className="group relative text-2xl font-semibold mt-10 mb-4"> {children} <a href={`#${id}`} className="absolute -left-6 opacity-0 group-hover:opacity-100" aria-label="Link to section" > # </a> </h2> ), a: ({ href, children }) => { if (href?.startsWith('/') || href?.startsWith('#')) { return <Link href={href}>{children}</Link> } return <a href={href} target="_blank" rel="noopener noreferrer">{children}</a> }, img: (props) => ( <Image {...(props as ImageProps)} className="rounded-lg my-6" sizes="(max-width: 768px) 100vw, 800px" /> ), pre: ({ children }) => ( <pre className="overflow-x-auto rounded-xl bg-zinc-950 p-4 my-6 text-sm"> {children} </pre> ), blockquote: ({ children }) => ( <blockquote className="border-l-4 border-primary pl-4 italic my-6 text-muted-foreground"> {children} </blockquote> ), ...components, } }
The h2 mapping above adds anchor links that appear on hover — the pattern you see in every good documentation site. The a mapping uses Next.js Link for internal URLs and a standard <a> with target="_blank" and rel="noopener noreferrer" for external ones. The img mapping replaces bare <img> tags with next/image for automatic optimization.
Frontmatter with gray-matter
MDX files often need metadata — title, description, date, author — that's separate from the rendered content. The frontmatter convention is YAML at the top of the file, delimited by ---:
mdx--- title: Getting Started with the API description: A complete guide to authenticating and making your first API call. publishedAt: 2025-03-15 author: Alice Chen tags: [api, authentication, quickstart] --- # Getting Started Install the SDK...
Parse it with gray-matter:
tsimport matter from 'gray-matter' import { readFile } from 'fs/promises' import path from 'path' export async function getDocPage(slug: string) { const filepath = path.join(process.cwd(), 'content/docs', `${slug}.mdx`) const raw = await readFile(filepath, 'utf-8') const { data: frontmatter, content } = matter(raw) return { frontmatter: frontmatter as { title: string description: string publishedAt: string }, content, } }
For static generation, read the file system at build time and pass frontmatter to generateMetadata to automatically populate the document's <title> and <meta description> from the MDX file. The metadata and the content come from the same source of truth.
Remote MDX from a CMS
When your MDX content lives in a database or headless CMS rather than the filesystem, use next-mdx-remote/rsc to render it server-side:
tsx// app/blog/[slug]/page.tsx import { MDXRemote } from 'next-mdx-remote/rsc' import { useMDXComponents } from '@/mdx-components' import { getPostBySlug } from '@/lib/cms' export default async function BlogPost({ params, }: { params: Promise<{ slug: string }> }) { const { slug } = await params const post = await getPostBySlug(slug) return ( <article> <h1>{post.title}</h1> <MDXRemote source={post.mdxContent} components={useMDXComponents({})} options={{ mdxOptions: { remarkPlugins: [], rehypePlugins: [], }, }} /> </article> ) }
One hard requirement for remote MDX: never render untrusted MDX. MDX compiles to JavaScript that executes in your server process. If a user can submit arbitrary MDX content that gets rendered without sanitization, they can execute arbitrary code on your server. For user-generated content, either strip JSX from the MDX before rendering (use a remark plugin to block JSX nodes), or use a sandboxed evaluation environment. For CMS content authored by your own team, this is less of a concern.
Syntax Highlighting with rehype-pretty-code
Syntax highlighting in MDX should happen at build time on the server, not in the browser with PrismJS or highlight.js. Server-side highlighting means zero client JavaScript, accurate colors in every rendering context, and consistent output across themes.
rehype-pretty-code with Shiki is the current best-in-class solution:
bashnpm install rehype-pretty-code shiki
ts// next.config.ts import rehypePrettyCode from 'rehype-pretty-code' const withMDX = createMDX({ options: { rehypePlugins: [ [ rehypePrettyCode, { theme: { dark: 'github-dark-dimmed', light: 'github-light', }, keepBackground: false, defaultLang: 'plaintext', }, ], ], }, })
With theme set to an object with dark and light keys, Shiki outputs CSS custom properties that respond to your site's color scheme. Set keepBackground: false and apply your own background in the pre component mapping in mdx-components.tsx to have full design control.
Code blocks in MDX then support language annotations, line highlighting, and word highlighting out of the box:
mdx```ts {3-5} /config/ import { config } from './config' // This line is highlighted const value = config.get('key') ```
The {3-5} highlights lines 3 through 5. The /config/ highlights every occurrence of the word "config". These features are purely declarative in your MDX content — no extra components needed.
A Complete MDX Docs Page with Table of Contents
Pulling it all together — a docs page that extracts headings for a Table of Contents, renders with custom components, and exposes frontmatter as metadata:
tsx// app/docs/[slug]/page.tsx import { MDXRemote } from 'next-mdx-remote/rsc' import rehypePrettyCode from 'rehype-pretty-code' import rehypeSlug from 'rehype-slug' import { getDocPage, getAllDocSlugs } from '@/lib/docs' import { TableOfContents } from '@/components/TableOfContents' import { useMDXComponents } from '@/mdx-components' import type { Metadata } from 'next' export async function generateStaticParams() { const slugs = await getAllDocSlugs() return slugs.map((slug) => ({ slug })) } export async function generateMetadata({ params, }: { params: Promise<{ slug: string }> }): Promise<Metadata> { const { slug } = await params const { frontmatter } = await getDocPage(slug) return { title: frontmatter.title, description: frontmatter.description } } export default async function DocsPage({ params, }: { params: Promise<{ slug: string }> }) { const { slug } = await params const { content, frontmatter, headings } = await getDocPage(slug) return ( <div className="flex gap-12"> <article className="flex-1 max-w-prose"> <h1>{frontmatter.title}</h1> <MDXRemote source={content} components={useMDXComponents({})} options={{ mdxOptions: { rehypePlugins: [rehypeSlug, [rehypePrettyCode, { theme: 'github-dark-dimmed' }]], }, }} /> </article> <aside className="hidden xl:block w-56 sticky top-8 self-start"> <TableOfContents headings={headings} /> </aside> </div> ) }
rehype-slug automatically adds id attributes to all headings in the MDX output. Your TOC component reads the heading IDs and renders anchor links. The headings array is extracted during the getDocPage call using a remark plugin that walks the AST before rendering.
View Transitions API in Next.js 15
The View Transitions API is a browser feature that animates elements between two DOM states — typically two page navigations. When you navigate from /blog to /blog/post-title, the browser can smoothly animate shared elements (a card expanding into a full-width hero, a thumbnail becoming a full image) instead of cutting abruptly.
Enable it in next.config.ts:
tsexperimental: { viewTransition: true, }
With this enabled, Next.js wraps client-side navigations in document.startViewTransition(). The default effect is a cross-fade between the old and new page. For most navigation this looks clean and modern with no additional work.
For element-level transitions — animating a specific card from the list into the detail page — use the view-transition-name CSS property to mark matching elements with the same name:
tsx// In the blog list — the card that will expand <article style={{ viewTransitionName: `post-${post.slug}` }} className="rounded-xl border p-6 cursor-pointer" > <img src={post.cover} style={{ viewTransitionName: `post-image-${post.slug}` }} alt={post.title} /> <h2>{post.title}</h2> </article> // In the blog detail page — the corresponding header <header> <img src={post.cover} style={{ viewTransitionName: `post-image-${post.slug}` }} alt={post.title} className="w-full aspect-video object-cover" /> <h1 style={{ viewTransitionName: `post-${post.slug}` }}> {post.title} </h1> </header>
When the names match across two pages, the browser animates the element from its position on the old page to its position on the new page. The effect is the card expanding into the full detail page — a navigation metaphor that's immediately intuitive to users.
Controlling View Transition Animations with CSS
The @view-transition rule and ::view-transition-* pseudo-elements let you customize the animation behavior:
css/* Enable view transitions for cross-document navigations (not needed for SPA mode) */ @view-transition { navigation: auto; } /* Slow down the default cross-fade */ ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 300ms; animation-timing-function: ease-in-out; } /* Custom animation for the post image */ ::view-transition-old(post-image-*) { animation: fade-out 200ms ease-out; } ::view-transition-new(post-image-*) { animation: slide-in 300ms ease-out; } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } @keyframes slide-in { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
Two constraints worth knowing upfront. View Transitions are same-origin only — you can't animate across domains or subdomains. And there's no streaming overlap: the browser waits for the new page's content to be ready before starting the animation. In a streaming Server Component architecture where content loads incrementally, the transition starts when the initial shell arrives, not when all the data is fetched. For pages with slow data, this means the animation plays and then the content loads. Design your loading states with this in mind.
Content-First Architecture
For applications where content is the product — documentation, developer portals, knowledge bases — the architectural decisions around content storage and retrieval matter more than framework configuration.
File-based content (MDX in your repository) scales well up to a few thousand pages. The entire content corpus can be read at build time, search indexes can be pre-built, and static generation produces fast pages with no database dependency. The tradeoff is that every content update requires a deployment. For documentation that updates alongside code releases, this is a feature. For documentation that needs to be updated independently, it's a constraint.
For larger catalogs or content that updates independently of deployments, a hybrid approach works well: store content in a headless CMS, cache it aggressively in Next.js with tag-based revalidation, and trigger revalidation via webhook when content is published. The Next.js app stays statically optimal most of the time, and content updates propagate within seconds of publication via revalidateTag.
The filesystem conventions that work well for file-based content systems: a content/ directory at the project root (not inside src/ or app/), subdirectories per content type (content/docs/, content/blog/, content/changelog/), frontmatter for all metadata, and a thin abstraction layer in src/lib/ that reads and parses files. Keep the abstraction thin — content pipelines that become complex black boxes are painful to debug. The content is just files; the code that reads it should be obvious.