How to build and register custom React components for MDX in Next.js — next/image, next/link, client components, code blocks, tabs, and callouts.
One of the best things about MDX is dropping React components right into your content. In Next.js, that means you get optimized images, client-side interactivity, and custom UI — all from within Markdown. Here's how to set that up.
Component Registration Methods
Three ways to make components available in your MDX:
mdx-components.tsx — Global components for @next/mdxcomponents prop — Per-page components with next-mdx-remote- Direct imports — With
mdx-bundler
MDXRemote Components Prop
When you're using next-mdx-remote, you pass components directly to the renderer.
App Router (Server Components)
// app/blog/[slug]/page.tsx
import { MDXRemote } from 'next-mdx-remote/rsc'
import { Callout } from '@/components/Callout'
import { CodeBlock } from '@/components/CodeBlock'
import { InteractiveDemo } from '@/components/InteractiveDemo'
import { Chart } from '@/components/Chart'
// Define component map
const mdxComponents = {
Callout,
CodeBlock,
InteractiveDemo,
Chart,
// Override HTML elements
h1: (props: any) => <h1 className="text-4xl font-bold" {...props} />,
pre: (props: any) => <CodeBlock {...props} />,
}
export default async function BlogPost({ params }: Props) {
const post = getPostBySlug(params.slug)
return (
<article className="prose prose-lg">
<MDXRemote
source={post.content}
components={mdxComponents}
/>
</article>
)
}
Pages Router
// pages/blog/[slug].tsx
import { MDXRemote } from 'next-mdx-remote'
import dynamic from 'next/dynamic'
// Lazy-load heavy components
const Chart = dynamic(() => import('@/components/Chart'))
const Playground = dynamic(() => import('@/components/Playground'))
const components = {
Callout,
Chart,
Playground,
pre: CodeBlock,
}
export default function BlogPost({ source, frontmatter }: Props) {
return (
<article>
<h1>{frontmatter.title}</h1>
<MDXRemote {...source} components={components} />
</article>
)
}
Using next/image in MDX
Next.js Image gives you automatic optimization. Wrap it in a component for easy MDX usage:
Image Component Wrapper
// components/MDXImage.tsx
import Image from 'next/image'
interface MDXImageProps {
src: string
alt: string
width?: number
height?: number
caption?: string
priority?: boolean
}
export function MDXImage({
src,
alt,
width = 800,
height = 450,
caption,
priority = false,
}: MDXImageProps) {
return (
<figure className="my-8">
<div className="relative overflow-hidden rounded-xl shadow-lg">
<Image
src={src}
alt={alt}
width={width}
height={height}
priority={priority}
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 700px"
/>
</div>
{caption && (
<figcaption className="text-center text-sm text-gray-500 mt-3 italic">
{caption}
</figcaption>
)}
</figure>
)
}
Usage in MDX
<MDXImage
src="/images/blog/architecture-diagram.png"
alt="System architecture diagram"
width={1200}
height={600}
caption="Figure 1: System architecture overview"
priority
/>
Using next/link in MDX
Smart Link Component
This auto-detects internal vs external links and handles them differently:
// components/MDXLink.tsx
import Link from 'next/link'
import { ExternalLink } from 'lucide-react'
interface MDXLinkProps {
href: string
children: React.ReactNode
}
export function MDXLink({ href, children }: MDXLinkProps) {
const isInternal = href.startsWith('/') || href.startsWith('#')
if (isInternal) {
return (
<Link
href={href}
className="text-blue-600 hover:text-blue-800 underline underline-offset-2 transition-colors"
>
{children}
</Link>
)
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline underline-offset-2 inline-flex items-center gap-1 transition-colors"
>
{children}
<ExternalLink className="w-3 h-3" />
</a>
)
}
Custom Code Blocks
Advanced Code Block with Syntax Highlighting
This gives you a proper code block with a copy button, filename display, and line numbers:
// components/CodeBlock.tsx
'use client'
import { useState } from 'react'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
interface CodeBlockProps {
children: React.ReactElement
}
export function CodeBlock({ children }: CodeBlockProps) {
const [copied, setCopied] = useState(false)
// Extract code content and language from the pre > code structure
const codeElement = children?.props ? children : null
const className = codeElement?.props?.className || ''
const language = className.replace('language-', '') || 'text'
const code = codeElement?.props?.children?.trim() || ''
// Extract filename from meta (e.g., ```tsx title="App.tsx")
const meta = codeElement?.props?.meta || ''
const filenameMatch = meta.match(/title="([^"]*)"/)
const filename = filenameMatch ? filenameMatch[1] : null
const handleCopy = async () => {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="relative group my-6 rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700">
{/* Header with filename and copy button */}
<div className="flex items-center justify-between px-4 py-2 bg-gray-800 border-b border-gray-700">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500" />
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<div className="w-3 h-3 rounded-full bg-green-500" />
</div>
{filename && (
<span className="text-sm text-gray-400 ml-2">{filename}</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 uppercase">{language}</span>
<button
onClick={handleCopy}
className="text-gray-400 hover:text-white text-sm transition-colors"
aria-label="Copy code"
>
{copied ? '✓ Copied!' : 'Copy'}
</button>
</div>
</div>
{/* Code content */}
<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{
margin: 0,
borderRadius: 0,
padding: '1.5rem',
fontSize: '0.875rem',
}}
showLineNumbers={code.split('\n').length > 3}
>
{code}
</SyntaxHighlighter>
</div>
)
}
Client Components in MDX
MDX in the App Router renders as Server Components by default. If you need interactivity (state, event handlers), you need to mark those components as Client Components with 'use client'.
Interactive Counter Example
// components/Counter.tsx
'use client'
import { useState } from 'react'
interface CounterProps {
initialValue?: number
step?: number
label?: string
}
export function Counter({
initialValue = 0,
step = 1,
label = 'Count',
}: CounterProps) {
const [count, setCount] = useState(initialValue)
return (
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg my-4 border">
<span className="text-lg font-medium">
{label}: {count}
</span>
<button
onClick={() => setCount(count - step)}
className="px-3 py-1 bg-red-100 text-red-700 rounded hover:bg-red-200"
>
-{step}
</button>
<button
onClick={() => setCount(count + step)}
className="px-3 py-1 bg-green-100 text-green-700 rounded hover:bg-green-200"
>
+{step}
</button>
<button
onClick={() => setCount(initialValue)}
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Reset
</button>
</div>
)
}
Usage in MDX
# Interactive Demo
Here's a live counter you can interact with:
<Counter initialValue={10} step={5} label="Score" />
The counter above demonstrates how client components work within MDX.
Callout / Alert Component
A must-have for any docs site:
// components/Callout.tsx
import { ReactNode } from 'react'
interface CalloutProps {
type?: 'info' | 'warning' | 'error' | 'success' | 'tip'
title?: string
children: ReactNode
}
const styles = {
info: {
bg: 'bg-blue-50 border-blue-200',
icon: 'ℹ️',
title: 'text-blue-800',
},
warning: {
bg: 'bg-yellow-50 border-yellow-200',
icon: '⚠️',
title: 'text-yellow-800',
},
error: {
bg: 'bg-red-50 border-red-200',
icon: '🚫',
title: 'text-red-800',
},
success: {
bg: 'bg-green-50 border-green-200',
icon: '✅',
title: 'text-green-800',
},
tip: {
bg: 'bg-purple-50 border-purple-200',
icon: '💡',
title: 'text-purple-800',
},
}
export function Callout({ type = 'info', title, children }: CalloutProps) {
const style = styles[type]
return (
<div className={`${style.bg} border rounded-lg p-4 my-6`}>
<div className="flex items-start gap-3">
<span className="text-xl">{style.icon}</span>
<div className="flex-1">
{title && (
<p className={`font-semibold ${style.title} mb-1`}>{title}</p>
)}
<div className="text-gray-700 [&>p]:mb-0">{children}</div>
</div>
</div>
</div>
)
}
Usage in MDX
<Callout type="warning" title="Breaking Change">
This API changed in version 3.0. See the migration guide for details.
</Callout>
<Callout type="tip">
You can nest **markdown** inside callouts including `code` and [links](/docs).
</Callout>
Tabs Component
Great for showing install commands across package managers, or multiple code examples:
// components/Tabs.tsx
'use client'
import { useState, ReactNode, Children, isValidElement } from 'react'
interface TabsProps {
children: ReactNode
defaultTab?: number
}
interface TabItemProps {
label: string
children: ReactNode
}
export function Tabs({ children, defaultTab = 0 }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab)
const tabs = Children.toArray(children).filter(
(child) => isValidElement(child) && child.type === TabItem
) as React.ReactElement<TabItemProps>[]
return (
<div className="my-6 border rounded-xl overflow-hidden">
{/* Tab Headers */}
<div className="flex border-b bg-gray-50">
{tabs.map((tab, index) => (
<button
key={index}
onClick={() => setActiveTab(index)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === index
? 'bg-white border-b-2 border-blue-600 text-blue-600'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{tab.props.label}
</button>
))}
</div>
{/* Tab Content */}
<div className="p-4">{tabs[activeTab]?.props.children}</div>
</div>
)
}
export function TabItem({ children }: TabItemProps) {
return <>{children}</>
}
Usage in MDX
<Tabs>
<TabItem label="npm">
npm install @next/mdx @mdx-js/loader
</TabItem>
<TabItem label="yarn">
yarn add @next/mdx @mdx-js/loader
</TabItem>
<TabItem label="pnpm">
pnpm add @next/mdx @mdx-js/loader
TypeScript Types for Custom Components
Type definitions for your MDX component map:
// types/mdx-components.ts
import { ImageProps } from 'next/image'
import { ReactNode } from 'react'
export interface MDXComponentMap {
Callout: React.FC<{
type?: 'info' | 'warning' | 'error' | 'success' | 'tip'
title?: string
children: ReactNode
}>
Counter: React.FC<{
initialValue?: number
step?: number
label?: string
}>
MDXImage: React.FC<{
src: string
alt: string
width?: number
height?: number
caption?: string
}>
Tabs: React.FC<{ children: ReactNode; defaultTab?: number }>
TabItem: React.FC<{ label: string; children: ReactNode }>
VideoEmbed: React.FC<{ url: string; title?: string }>
}
Summary
Custom components are what make MDX so much more than Markdown. Here's the quick version:
mdx-components.tsx for global component registration in the App Routercomponents prop for per-page customization with next-mdx-remote'use client' directive for anything interactive- Override default elements (
h1, p, a, pre) for consistent styling - Wrap Next.js components (
Image, Link) for MDX compatibility - Build reusable UI (Callout, Tabs, CodeBlock) for rich content