Skip to content
Writing

5 min readupdated 2 days ago

Intro to mdx-bundler with NextJs

In this blog post, I'll show you how to use mdx-bundler with NextJS

  • mdx
  • Next.js
  • Tanstack/Markdown

I've written this blog in MDX for a while now, and mdx-bundler is the piece that made it painless. In this post I'll cover what MDX is, what mdx-bundler adds on top of it, and how to wire it up in a Next.js project, including a small plugin that lets code blocks carry their own metadata.

Where this blog is today

This site has since moved off MDX to TanStack Markdown, which parses plain Markdown and renders it on the server. The mdx-bundler setup below is exactly what powered the earlier versions, and it's still a great fit if you want real React components inside your posts.

What is MDX?

MDX is Markdown with superpowers: you can import and use React components right inside the file. I'll assume you're comfortable with Markdown. If not, the CommonMark guide is a ten-minute read that covers everything you need.

There are three packages most people reach for to compile MDX:

I use mdx-bundler for my blog, with the content coming from Hygraph. The key difference from next-mdx-remote is scope. next-mdx-remote is purely a compiler, while mdx-bundler compiles and bundles, so a post can import its own components and dependencies. It also handles frontmatter out of the box.

What is mdx-bundler?

mdx-bundler is an async function that compiles an MDX file and bundles it with everything it imports. Because bundling happens on demand, it suits frameworks that render on the server, and Next.js is a perfect match.

Start with a Next.js project, then install the bundler along with esbuild, which does the heavy lifting:

Terminal
# npm
npm install mdx-bundler esbuild

# yarn
yarn add mdx-bundler esbuild

You can bundle at build time (SSG) or on every request (SSR). I went with build time plus incremental static regeneration, so pages are fast and still refresh whenever I publish an update.

Pick the cheapest option that works

Build-time bundling costs the reader nothing. Only bundle per request if your content changes so often that regeneration can't keep up.

Bundling your MDX

Import bundleMDX from the package and pass it your content. There are two ways to hand it that content: source, a string of MDX, or file, a path to an .mdx file on disk.

bundle.js
bundleMDX({
  source: /* MDX string */,
  // or
  file: /* path to an .mdx file */,
})

Since my posts live in a CMS, I use source. You can see every available option in the mdx-bundler docs.

Adding remark and rehype plugins

The mdxOptions callback is where you plug into the compiler. Anything you add to remarkPlugins or rehypePlugins runs while your MDX is transformed:

bundle.js
bundleMDX({
  source,
  mdxOptions(options, frontmatter) {
    options.remarkPlugins = [...(options.remarkPlugins ?? []), myRemarkPlugin];
    options.rehypePlugins = [...(options.rehypePlugins ?? []), myRehypePlugin];

    return options;
  },
});

A plugin for code block metadata

I wanted code blocks to accept extra attributes, like a title or highlighted lines, written next to the language. Inspired by Pedro's article on better code blocks, I wrote a small rehype plugin. It reads the text after the language name and copies each key=value pair onto the rendered element:

rehype-meta-attribute.ts
import { visit } from 'unist-util-visit';

const re = /\b([-\w]+)(?:=(?:"([^"]*)"|'([^']*)'|([^"'\s]+)))?/g;

export const rehypeMetaAttribute = () => {
  return (tree: any) => {
    visit(tree, 'element', visitor);
  };

  function visitor(node: any, index: any, parentNode: any) {
    let match;

    if (node.tagName === 'code' && node.data && node.data.meta) {
      re.lastIndex = 0; // Reset the regex between nodes.

      while ((match = re.exec(node.data.meta))) {
        node.properties[match[1]] = match[2] || match[3] || match[4] || '';
        parentNode.properties[match[1]] = match[2] || match[3] || match[4] || '';
      }
    }
  }
};

Because it works on the HTML tree, it's a rehype plugin, so it goes in rehypePlugins:

bundle.js
mdxOptions(options) {
  options.rehypePlugins = [
    ...(options.rehypePlugins ?? []),
    rehypeMetaAttribute,
  ];

  return options;
}

Rendering on the client

The bundler gives you a string of code, so the last step is turning it into a component in the browser. Import getMDXComponent from mdx-bundler/client and wrap the call in useMemo. Turning that code into a component is expensive, and you only want to redo it when the code changes, so code goes in the dependency array.

mdx-page.tsx
import * as React from 'react';
import { getMDXComponent } from 'mdx-bundler/client';

function MDXPage({ code }: { code: string }) {
  const Component = React.useMemo(() => getMDXComponent(code), [code]);

  return <Component />;
}

Swapping in your own components

The generated component accepts a components prop. Anything you pass there replaces the matching element in your MDX, and you can add brand-new components too:

mdx-page.jsx
<Component
  components={{
    pre: CodeBlock,
    Callout,
  }}
/>

Here I replace the default pre tag with my own styled CodeBlock and register a Callout component so posts can use it directly.

Wrapping up

That's the whole setup: bundle the MDX on the server, turn it into a component on the client, and swap in your own components where you want them. If you run into trouble or just want to chat about it, find me on X at @jana__sundar or email me at mailtojana23@gmail.com. Until next time, happy coding!