
Creating Custom MDX Components and Shortcodes in Next.js
Plain Markdown gets you headings, paragraphs, lists, links, and code. That covers most of a blog post, but sooner or later you want something it can't express: a warning box, a set of tabs showing the same command for npm and pnpm, an embedded video, an image with a caption. In WordPress these are called shortcodes. In MDX, they're just React components you can write directly in your content.
MDX gives you two related tools. You can override the HTML elements that Markdown produces, so every link, heading, or table on your site gets the same behavior and styling. And you can register named components that authors use like tags inside a post. This post covers both: how the component map works, how to wire it up with @next/mdx and with next-mdx-remote, a set of practical shortcodes, and the pitfalls around props, Server and Client Components, and Markdown inside JSX.
How MDX Resolves Components
When MDX compiles a file, every Markdown construct becomes a JSX element. ## Setup becomes an h2, [docs](/docs) becomes an a, a fenced block becomes pre with a code inside. Before rendering, MDX looks each element name up in a components map, an object you provide. If the map has an entry for h2, your component renders instead of a plain h2.
The same map is used for capitalized names. When a post contains <Callout>, MDX looks for Callout in the map. If it's missing, rendering fails with an error like "Expected component Callout to be defined", which is the most common MDX error you'll see.
So there's one mechanism for both jobs:
| Key in the map | Written in content as | Use |
|---|---|---|
a, h2, img, table, pre... | Normal Markdown | Change how built-in elements render |
Callout, YouTube, Tabs... | <Callout>...</Callout> | Shortcodes: new building blocks for authors |
Organizing the Components
Keep every MDX component in one folder and export a single map. Both rendering approaches can then share it.
components/
mdx/
index.tsx # the components map
heading.tsx
mdx-link.tsx
callout.tsx
youtube.tsx
figure.tsx
tabs.tsx # "use client"
// components/mdx/index.tsx
import type { MDXComponents } from "mdx/types";
import { createHeading } from "./heading";
import { MdxLink } from "./mdx-link";
import { Callout } from "./callout";
import { YouTube } from "./youtube";
import { Figure } from "./figure";
import { Tabs, Tab } from "./tabs";
export const mdxComponents: MDXComponents = {
// Built-in element overrides
h2: createHeading(2),
h3: createHeading(3),
a: MdxLink,
table: (props) => (
<div className="my-6 overflow-x-auto">
<table {...props} />
</div>
),
img: ({ alt = "", ...props }) => (
<img alt={alt} loading="lazy" decoding="async" {...props} />
),
// Shortcodes
Callout,
YouTube,
Figure,
Tabs,
Tab,
};
The file is .tsx because the table and img overrides are written inline as JSX. The MDXComponents type comes from @types/mdx (npm install -D @types/mdx) and gives you correct prop types for every HTML element override.
Wiring it up with @next/mdx
If your MDX files are compiled by the bundler through @next/mdx, the map goes in the required mdx-components.tsx file at the project root (or in src/ if you use one):
// mdx-components.tsx
import type { MDXComponents } from "mdx/types";
import { mdxComponents } from "@/components/mdx";
export function useMDXComponents(): MDXComponents {
return mdxComponents;
}
Every .mdx file in the app now uses these components. useMDXComponents takes no arguments. For page-specific overrides, pass a components prop when rendering an imported MDX module: <Post components={{ h2: SpecialHeading }} />. Those merge with, and win over, the global map.
Wiring it up with next-mdx-remote
If you load MDX as strings, as in building a Markdown-powered blog with Next.js and MDX, pass the map to MDXRemote:
// components/mdx-content.tsx
import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import { mdxComponents } from "@/components/mdx";
export function MdxContent({ source }: { source: string }) {
return (
<MDXRemote
source={source}
components={mdxComponents}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeSlug],
},
}}
/>
);
}
Everything below works the same in both setups, with one exception around props that I'll come back to.
Overriding Built-in Elements
Links: client-side navigation for internal URLs
Markdown links render as plain a elements, so clicking a link to another post triggers a full page load. Routing internal links through next/link gives you client-side navigation and prefetching:
// components/mdx/mdx-link.tsx
import Link from "next/link";
import type { ComponentPropsWithoutRef } from "react";
export function MdxLink({
href = "",
...props
}: ComponentPropsWithoutRef<"a">) {
if (href.startsWith("/")) {
return <Link href={href} {...props} />;
}
if (href.startsWith("#")) {
return <a href={href} {...props} />;
}
return <a href={href} target="_blank" rel="noopener noreferrer" {...props} />;
}
Three cases: site-relative paths go through Link, in-page anchors stay as plain links, and everything else is treated as external and opens in a new tab with rel="noopener noreferrer".
Headings with anchor links
Readers like to link to a specific section. With rehype-slug adding an id to each heading, an override can wrap the text in a self-link:
// components/mdx/heading.tsx
import type { ComponentPropsWithoutRef } from "react";
export function createHeading(level: 2 | 3) {
const Tag = `h${level}` as const;
function Heading({ id, children, ...props }: ComponentPropsWithoutRef<"h2">) {
return (
<Tag id={id} className="group scroll-mt-24" {...props}>
{id ? (
<a href={`#${id}`} className="no-underline">
{children}
<span
aria-hidden
className="ml-2 opacity-0 transition group-hover:opacity-50"
>
#
</span>
</a>
) : (
children
)}
</Tag>
);
}
Heading.displayName = `MdxHeading${level}`;
return Heading;
}
createHeading returns a component for a given level so h2 and h3 share the same logic. scroll-mt-24 leaves room for a sticky header when jumping to an anchor. The # marker is decorative and hidden from screen readers. If there's no id (for example, rehype-slug isn't installed), it falls back to a plain heading.
Tables that don't break the layout
Wide tables overflow narrow screens and push the whole page sideways. The table override in the map wraps every table in a horizontally scrollable div, which fixes that once for every post.
Images
You might expect to map img straight to next/image. The catch is that next/image requires width and height (or fill), and Markdown's  syntax has no way to provide them. Without dimensions, next/image throws at render time.
The pragmatic split:
- Map Markdown
imgto a plainimgwithloading="lazy"anddecoding="async", as in the map above. Style it withmax-width: 100%andheight: auto. - Provide a
Figureshortcode that takes explicit dimensions and usesnext/image, for images where size and quality matter.
// components/mdx/figure.tsx
import Image from "next/image";
type FigureProps = {
src: string;
alt: string;
width: string;
height: string;
caption?: string;
};
export function Figure({ src, alt, width, height, caption }: FigureProps) {
return (
<figure className="my-8">
<Image
src={src}
alt={alt}
width={Number(width)}
height={Number(height)}
sizes="(min-width: 768px) 720px, 100vw"
className="h-auto w-full rounded-lg"
/>
{caption && (
<figcaption className="mt-2 text-center text-sm text-gray-500">
{caption}
</figcaption>
)}
</figure>
);
}
Notice that width and height are typed as strings and converted with Number(). That's deliberate, and the next section explains why.
Props in MDX: Use Strings
In JSX you'd write width={1200}. In MDX you can write the same thing, but whether it survives depends on how you render.
next-mdx-remote version 6 and later blocks JavaScript expressions in MDX by default for security. That includes {...} in text and expression-valued attributes like width={1200} or items={["a", "b"]}. Those attributes are silently removed before rendering. String attributes (width="1200") and boolean attributes (open) are kept.
You can turn expressions back on with blockJS: false in the options for trusted content, but there's a simpler habit that works everywhere: design shortcodes to take string props and convert them inside the component. Then a post looks like this:
<Figure
src="/images/blog/dashboard.png"
alt="The analytics dashboard with three charts"
width="1600"
height="900"
caption="The new dashboard layout."
/>
It reads cleanly for authors, works with @next/mdx and next-mdx-remote regardless of settings, and keeps content free of code. For lists of values, accept a comma-separated string and split it in the component.
Building Shortcodes
Callout
A callout highlights a tip, warning, or note. It's the shortcode most blogs reach for first.
// components/mdx/callout.tsx
import type { ReactNode } from "react";
const styles = {
note: "border-sky-500 bg-sky-50 dark:bg-sky-950/40",
tip: "border-emerald-500 bg-emerald-50 dark:bg-emerald-950/40",
warning: "border-amber-500 bg-amber-50 dark:bg-amber-950/40",
} as const;
const labels = { note: "Note", tip: "Tip", warning: "Warning" } as const;
type CalloutProps = {
type?: keyof typeof styles;
title?: string;
children: ReactNode;
};
export function Callout({ type = "note", title, children }: CalloutProps) {
const variant = type in styles ? type : "note";
return (
<aside
role="note"
className={`my-6 rounded-r-lg border-l-4 px-4 py-3 ${styles[variant]}`}
>
<p className="mb-1 font-semibold">{title ?? labels[variant]}</p>
<div className="[&>p]:my-1">{children}</div>
</aside>
);
}
In a post:
<Callout type="warning" title="Breaking change">
In Next.js 16, `params` is a promise. Await it before reading values.
</Callout>
The blank lines inside the component matter. MDX treats content on the same line as the tags as inline text, while content separated by blank lines is parsed as full Markdown, so the inline code, bold text, and lists inside the callout work as expected. Forgetting the blank lines is the second most common MDX surprise after missing components.
The type in styles check guards against a typo like type="warn", falling back to a note instead of rendering an unstyled box.
YouTube embed
A video embed with a privacy-friendlier domain and a proper aspect ratio:
// components/mdx/youtube.tsx
type YouTubeProps = {
id: string;
title: string;
start?: string;
};
export function YouTube({ id, title, start }: YouTubeProps) {
const params = new URLSearchParams({ rel: "0" });
if (start) params.set("start", start);
return (
<div className="my-8 aspect-video overflow-hidden rounded-lg">
<iframe
src={`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?${params}`}
title={title}
loading="lazy"
allow="accelerometer; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="h-full w-full border-0"
/>
</div>
);
}
<YouTube id="dQw4w9WgXcQ" title="Conference talk on caching" start="90" />
loading="lazy" stops the iframe, and the several hundred kilobytes of player script that come with it, from loading until it's near the viewport. A required title gives the iframe an accessible name. encodeURIComponent keeps an odd id value from breaking out of the URL.
Tabs: an interactive shortcode
Tabs need state, so they have to be a Client Component. This is where the server/client boundary starts to matter.
// components/mdx/tabs.tsx
"use client";
import {
Children,
isValidElement,
useId,
useState,
type ReactElement,
type ReactNode,
} from "react";
type TabProps = { label: string; children: ReactNode };
export function Tab({ children }: TabProps) {
return <>{children}</>;
}
export function Tabs({ children }: { children: ReactNode }) {
const tabs = Children.toArray(children).filter(
(child): child is ReactElement<TabProps> =>
isValidElement<TabProps>(child) && typeof child.props.label === "string",
);
const [active, setActive] = useState(0);
const baseId = useId();
if (tabs.length === 0) return null;
return (
<div className="my-6 rounded-lg border">
<div role="tablist" className="flex border-b">
{tabs.map((tab, i) => (
<button
key={tab.props.label}
type="button"
role="tab"
id={`${baseId}-tab-${i}`}
aria-selected={active === i}
aria-controls={`${baseId}-panel-${i}`}
onClick={() => setActive(i)}
className={`px-4 py-2 text-sm ${
active === i
? "border-b-2 border-current font-semibold"
: "opacity-70"
}`}
>
{tab.props.label}
</button>
))}
</div>
{tabs.map((tab, i) => (
<div
key={tab.props.label}
role="tabpanel"
id={`${baseId}-panel-${i}`}
aria-labelledby={`${baseId}-tab-${i}`}
hidden={active !== i}
className="px-4"
>
{tab.props.children}
</div>
))}
</div>
);
}
<Tabs>
<Tab label="npm">
```bash
npm install next-mdx-remote
```
</Tab>
<Tab label="pnpm">
```bash
pnpm add next-mdx-remote
```
</Tab>
</Tabs>
A few things make this work:
- Both
TabsandTablive in the"use client"file.Tabsreads each child'slabelprop. That's only possible if the children arrive as elements with their props intact, which is the case whenTabis a Client Component. IfTabwere a Server Component, it would already be rendered by the timeTabsreceived it, and its props would be gone. - The panel content is still rendered on the server. The code blocks inside each tab come from MDX and are passed through as
children. Only the tab-switching logic ships as JavaScript. - All panels are in the HTML, with inactive ones
hidden. Search engines and readers without JavaScript still get the content, and switching tabs is instant. - ARIA roles and ids (
tablist,tab,tabpanel,aria-selected,aria-controls) make the tabs understandable to screen readers.useIdkeeps ids unique when a post has several tab groups. For full keyboard support, add arrow-key handling between tabs.
Accordions without JavaScript
Not every interactive-looking shortcode needs client code. A collapsible section works with native HTML:
// components/mdx/details.tsx
import type { ReactNode } from "react";
export function Details({
summary,
children,
}: {
summary: string;
children: ReactNode;
}) {
return (
<details className="my-4 rounded-lg border px-4 py-2">
<summary className="cursor-pointer font-medium">{summary}</summary>
<div className="mt-2">{children}</div>
</details>
);
}
details and summary handle open/close state, keyboard interaction, and accessibility in the browser. It stays a Server Component and ships zero JavaScript. Prefer this kind of solution whenever the platform already does the job. Add it to the map as Details to use it.
Server or Client: A Quick Rule
Every component in the map is a Server Component unless its file starts with "use client". Keep it that way for anything that only renders markup: callouts, figures, headings, links, embeds. Mark a component as a client one only when it needs state, effects, or event handlers, and keep that client part as small as possible.
| Shortcode | Needs client JS? | Why |
|---|---|---|
| Callout, Figure, YouTube, Details | No | Pure markup; the browser handles iframes and details |
| Tabs | Yes | Tracks the active tab |
| Copy-to-clipboard button on code blocks | Yes | Uses the Clipboard API on click |
| Interactive chart or demo | Yes | Stateful UI |
You can mix them freely in content. A Server Component callout can contain a Client Component tab group, and a client Tabs can receive server-rendered Markdown as children. For more on that boundary, see composition patterns for mixing Server and Client Components.
Code blocks deserve their own treatment: overriding pre to add a copy button and wiring in a highlighter is covered in adding syntax highlighting to code blocks in a Next.js blog.
Authoring Tips and Pitfalls
- Names must be capitalized.
<callout>is treated as an HTML element, not your component. - Leave blank lines around Markdown inside components, as in the callout and tabs examples. Without them, Markdown syntax inside the tags isn't parsed.
- Use strings for props. It's the one style that works under every MDX setup, including the secure defaults in
next-mdx-remote. - Escape literal braces and angle brackets in prose. In MDX, a bare
{starts an expression and a bare<can start a tag. Wrap them in inline code, or escape them with a backslash. - Keep shortcodes few and stable. Every shortcode is an API your old posts depend on. Renaming a prop means updating every post that uses it, so choose names carefully and avoid removing components.
- Make missing components fail loudly. An unknown component name breaks the build of that page, which is good. Don't paper over it with a catch-all fallback that silently renders nothing.
- Document them for authors. A single "style guide" post in your drafts that uses every shortcode doubles as documentation and a visual regression check.
Conclusion
MDX components come down to a single map. Lowercase keys like a, h2, img, and table change how Markdown renders across every post; capitalized keys like Callout, YouTube, Figure, and Tabs become shortcodes authors can drop into content. Share one map between mdx-components.tsx and MDXRemote, give shortcodes string props so they work with secure defaults, keep them as Server Components unless they truly need state, and leave blank lines around Markdown inside JSX. With a handful of well-designed components, your posts can do far more than plain Markdown without turning content into code.


