Type something to search...
Creating Custom MDX Components and Shortcodes in Next.js

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 mapWritten in content asUse
a, h2, img, table, pre...Normal MarkdownChange 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 ![alt](src) syntax has no way to provide them. Without dimensions, next/image throws at render time.

The pragmatic split:

  • Map Markdown img to a plain img with loading="lazy" and decoding="async", as in the map above. Style it with max-width: 100% and height: auto.
  • Provide a Figure shortcode that takes explicit dimensions and uses next/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 Tabs and Tab live in the "use client" file. Tabs reads each child's label prop. That's only possible if the children arrive as elements with their props intact, which is the case when Tab is a Client Component. If Tab were a Server Component, it would already be rendered by the time Tabs received 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. useId keeps 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.

ShortcodeNeeds client JS?Why
Callout, Figure, YouTube, DetailsNoPure markup; the browser handles iframes and details
TabsYesTracks the active tab
Copy-to-clipboard button on code blocksYesUses the Clipboard API on click
Interactive chart or demoYesStateful 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.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading