Next.js static site with MDX + dynamic routes + metadata

How to use MDX + dynamic routes + metadata in your statically generated Next.js app.
2020-07-03

So you want to build a site in React using Next.js? Excellent choice. That’s what powers this website today.

You also want to statically generate it for hosting on GitHub Pages or Netlify? Me too!

By default, pages in Next.js are written as React components. But for content like a blog, you’re writing mostly prose, not code. That’s better in a simpler format like Markdown. But sometimes you might want to sprinkle a little code into your content. MDX solves this problem:

MDX is an authorable format that lets you seamlessly write JSX in your Markdown documents.

Next.js has official support for MDX via its @next/mdx package, but it’s not clear how to support dynamic routes, nor how to include metadata to each post like title, description, publish date, etc.

But I can show you how! 🦸‍♂️

Start with a basic dynamic page for your posts:

pages/posts/[slug].tsx

export default function Post() {
  return <article>My Post!</article>;
}

Suppose you want to keep your posts under content/posts/my-blog-title/index.mdx. Here’s an example:

content/posts/my-blog-title/index.mdx

export const metadata = {
  title: "Next.js with MDX + dynamic routes + metadata",
  date: "2020-07-03",
};

So you want to build a site using Next.js?

For Next.js to build our site, it has to know about our posts. Let’s write a function to grab the list:

src/fetchPostSlugs.ts

import fs from "fs";
import path from "path";

export const fetchPostSlugs = () =>
  fs.promises.readdir(path.join(process.cwd(), "content/posts"));

Now, use that function in your page’s getStaticPaths function - now Next.js will know the list of pages to generate.

pages/posts/[slug].tsx

import { fetchPostSlugs } from "../../src/fetchPostSlugs";

// ...

export async function getStaticPaths() {
  const slugs = await fetchPostSlugs();

  return {
    paths: slugs?.map((slug) => ({ params: { slug } }));,
    fallback: false, // In a static-only build, we don't need fallback rendering.
  };
}

Next, define a getStaticProps that will grab the metadata from the MDX file, and give it to your Post component as a prop:

pages/posts/[slug].tsx

export const getStaticProps = (ctx) => {
  const slug = ctx.params?.slug;

  return {
    props: {
      slug,
      metadata: require(`../content/posts/${slug}/index.mdx`).metadata,
    },
  };
};

For client-side rendering, use next/dynamic to load your MDX component and render:

pages/posts/[slug].tsx

import dynamic from "next/dynamic";
import { fetchPostSlugs } from "../../src/fetchPostSlugs";

export default function Post({ slug, metadata }) {
  const Mdx = dynamic(() => import(`../../../content/posts/${slug}/index.mdx`));

  return (
    <article>
      <h1>{metadata.title}</h1>
      <small>{metadata.date}</small>
      <Mdx />
    </article>
  );
}

// ...

Unfortunately, next/dynamic doesn’t support static rendering or server-side rendering (SSR) when used with dynamic MDX file path like this. But we can fix that by manually rendering the content with react-dom/render:

pages/posts/[slug].tsx

import dynamic from "next/dynamic";
import { fetchPostSlugs } from "../../src/fetchPostSlugs";

export default function Post({ slug, metadata }) {
  let mdx;

  if (process.browser) {
    const Mdx = dynamic(() => import(`../../content/posts/${slug}/index.mdx`));

    mdx = <Mdx />;
  } else {
    const Component = require(`../../content/posts/${slug}/index.mdx`).default;
    const ReactDOMServer = require("react-dom/server");

    const ssr = ReactDOMServer.renderToString(<Component />) as string;

    mdx = <div dangerouslySetInnerHTML={{ __html: ssr }} />;
  }

  return (
    <article>
      <h1>{metadata.title}</h1>
      <small>{metadata.date}</small>
      {mdx}
    </article>
  );
}

// ...

And that’s all you need. Magic! ✨