Skip to content

Setting Up MDX with Next.js

Posted on:September 11, 2021 at 09:03 AM

UPDATE 2023-01-08: I’ve moved my site from Next.js to Astro, I’m leaving this post up as it may help others in the future.

This blog was generated using Next.js Typescript blog template which was fantastic to use to get everything up and running. I also wanted to be able to embed componets on top of writing posts. To do so I chose mdx-enhanced by Kent C. Dodds as it works as a compiler and a transpiler. It was also recommneded by Josh W. Comeau in this post talking about how he set up his blog.

What is MDX?

MDX is an enhancement to Markdown that supports writing JSX in Markdown documents. For my purposes it means that I can write React components and render them in my blog! All the links on this page are using a component for just this purpose!

Changes in order to get it working

By default the blog starter template uses grey-matter which will be swapped out for mdx-bundler. First step, get the dependencies:

npm install --save mdx-bundler esbuild

esbuild is required per the docs on mdx-bundlers site

Now that we have our dependencies set up, we need to switch from remark to mdx-bundler.

First, let’s crack open lib/api.ts where all the code lives to find all posts and generate an individual post. We’ll start with changing the function getPostBySlug from:

export function getPostBySlug(slug: string, fields: string[] = []) {
  const realSlug = slug.replace(/\.md$/, "");
  const fullPath = join(postsDirectory, `${realSlug}.md`);
  const fileContents = fs.readFileSync(fullPath, "utf8");
  const { data, content } = matter(fileContents);

  type Items = {
    [key: string]: string;
      items[field] = realSlug;
    }
    if (field === "content") {
      items[field] = content;
    }

    if (data[field]) {
      items[field] = data[field];
    if (frontmatter[field]) {
      items[field] = frontmatter[field];
    }
  });

  return items;
}

to this:

export async function getPostBySlug(slug: string, fields: string[] = []) {
  const realSlug = slug.replace(/\.md$/, "");
  const fullPath = join(postsDirectory, `${realSlug}.md`);
  const fileContents = fs.readFileSync(fullPath, "utf8");

  const { code, frontmatter } = await bundleMDX(fileContents, {
    cwd: dirname(fullPath),
  });

  type Items = {
    [key: string]: string;
      items[field] = realSlug;
    }
    if (field === "content") {
      items[field] = code;
    }

    if (data[field]) {
      items[field] = data[field];
    if (frontmatter[field]) {
      items[field] = frontmatter[field];
    }
  });

  return items;
}

First thing was to import { bundleMDX } from 'mdx-bundler' and then call bundleMDX with the contents of a given blog post. This is an async function so it needs to be await-ed in order to get the result. Let’s change the signature of getPostBySlug to be async also. bundleMDX returns an object with properties of code and frontmatter. code contains the contents of the Markdown post with any JSX already injected into it. frontmatter contains all our metadata of a post and uses grey-matter to retrieve it all. This made for a simple transition to support all the existing frontmatter properties I was already using. I did take an extra step to ensure that mdx-bundler would work when building on a Unix system or Windows system and included this code:

if (process.platform === "win32") {
  process.env.ESBUILD_BINARY_PATH = join(
    process.cwd(),
    "node_modules",
    "esbuild",
    "esbuild.exe"
  );
} else {
  process.env.ESBUILD_BINARY_PATH = join(
    process.cwd(),
    "node_modules",
    "esbuild",
    "bin",
    "esbuild"
  );
}

This tells node to find the proper binary from node_modules regardless of which system it’s run on.

Now that’s set up we need to change any locations that call this function to await a result. The first one I changed was getAllPosts which is in lib/api.ts. Before:

export function getAllPosts(fields: string[] = []) {
  const slugs = getPostSlugs();
  const posts = slugs.map((slug) => getPostBySlug(slug, fields));
  posts
    // sort posts by date in descending order
    .sort((post1, post2) => (post1.date > post2.date ? -1 : 1));

  return posts;
}

after:

export async function getAllPosts(fields: string[] = []) {
  const slugs = getPostSlugs();
  let posts = (
    await Promise.all(slugs.map((slug) => getPostBySlug(slug, fields)))
  ).filter((p) => p);

  posts
    // sort posts by date in descending order
    .sort((post1, post2) => (post1.date > post2.date ? -1 : 1));

  return posts;
}

Here we need to update the function to be async too so we can await Promise.all(). We’ll take all the post slugs that were found on the filesystem from getPostSlugs() and send each one into the updated getPostBySlug() function. Using Promise.all() means we can execute them all in parallel and await the final result. The extra filter() function applied removes and empty entries in the array. From there the sort by data logic is unchanges as the posts array is already populated. You’ll now need to search for all other calling locations of these two functions to update them to be await-ed. This included pages/index.tsx and pages/posts/[slug].tsx from the template.

Now to update our [slug].tsx file to render the MDX post. We’ll need to import { getMDXComponent } from "mdx-bundler/client" and in our Post component we’ll wrap the call to getMDXComponent in a useMemo() like this:

const Component = React.useMemo(() => getMDXComponent(post?.content), [post]);

This will reduce re-render calls when the data hasn’t changed. The post and post.content that’s being passed in come from getStaticProps which as been async-ified along with getStaticPaths. With this complete mdx-bundler is now parsing JSX from within Markdown files and rendering them out! 🎉

Troubleshooting

If you see any errors around Unexpected token '<' that most likely means that somewhere along your Markdown processing pipeline you trying to convert to HTML still. I ran into this when I forgot that getStaticProps was still being called with the markdown content.

Shout outs

I leaned very heavily on a couple of posts in order to get this all set up. One from Josh W. Comeau and the other from notJustDev. They helped immensily in deciding what technology to use and thank you to notjustDev for having their site open source so I could review how it was implemented.

✌🏻