Vincent Stollenwerk

How to Setup Dynamic Metadata and Layouts for MDX Frontmatter

Posted on

Recently, I've rewritten this website using Next.js and MDX. In this post, I'll explain how you can create layouts with access to the frontmatter of your MDX files when using @next/mdx.

Background

Before diving deeper into the topic, I'll give a short overview over the relevant technologies. If you're already familiar with Next.js and MDX, feel free to skip this section.

MDX and Frontmatter

First, MDX is an extension of Markdown, which allows users to include JSX expressions in their markdown content. Frontmatter is another Markdown extension which you can use to add metadata to your Markdown/MDX files. Here is what a MDX file with frontmatter looks like:

---
title: "how to win at life"
publication_date: 2024-07-01
published: true
---

import FancyComponent from "./fancy-component.tsx";

# How to win at life
It's simple, you just need to...

<FancyComponent />

On my site, I wanted to use the frontmatter, to set the document title and display the publication date as part of the webpage.

Setup

To enable MDX and frontmatter, I assume that you have installed the @next/mdx package and added the remark-frontmatter, as well as the remark-mdx-frontmatter plugins.

import nextMDX from '@next/mdx';
import remarkFrontmatter from 'remark-frontmatter';
import remarkMdxFrontmatter from 'remark-mdx-frontmatter';

const withMDX = nextMDX({
  options: {
    remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
  },
});

export default withMDX(nextConfig);

Building the layout

By default, the remark-mdx-frontmatter plugin exports the frontmatter as a Javascript Object named frontmatter. This means:

---
title: "how to win at life"
publication_date: 2024-07-01
published: true
---

Bla bla

Conceptually is turned into:

export const frontmatter = {
  title: "how to win at life",
  publication_date: "2024-07-01"
  published: true
};

export default function MDXContent() {
  return <p>Bla bla</p>
};

As a result, the simplest way to add a layout to your MDX file is to create a layout component:

// post-layout.tsx
export default function PostLayout({ children, frontmatter }: {
  content: React.ReactNode;
  frontmatter: {
    title: string
    date: string
    published: boolean
  };
}): JSX.Element {
  const publicationDate = new Date(frontmatter.date)
  const formattedDate = publicationDate.toLocaleDateString("en-US")

  return (
    <article>
      <h1>{title}</h1>
      <p>
        Posted on <time dateTime={frontmatter.date}>{formattedDate}</time>
      </p>
      { children }
    </article>
  );
};

And import (and export) it directly in the MDX file:

---
title: "how to win at life"
publication_date: 2024-07-01
published: true
---

import PostLayout from "./post-layout.tsx";

Your content...

export default({ children }) => (
  <PostLayout frontmatter={frontmatter}>
    { children }
  </PostLayout>
);

However, this approach has a few downsides:

  • It does not scale well, because you have to manually export the layout component from each MDX file.
  • It still does not give us a way to export dynamic metadata.
  • I like to keep my MDX files as close to plain Markdown as possible for portability.

Consequentially, I decided to go with another approach:

Using dynamic routing

The previous setup is independent of your project structure. Therefore, I used the built-in static routing of Next.js. However, switching to dynamic routing gives us more flexibility. Therefore, my project structure now looks like the following:

app
└── blog
    ├── [slug]
    │   ├── page.tsx
    │   └── post-layout.tsx
    └── _content
        ├── hello-world
        │   ├── page.mdx
        │   └── meme.gif
        ├── amazon-internship
        │   └── page.mdx
        └── ...

Where [slug]/page.tsx contains the layout and the content folder contains my different posts. This way, [slug]/page.tsx is rendered for any URL https://vstollen.me/blog/[slug].

In [slug]/page.tsx itself, I dynamically import the correct post and embed it into its surrounding layout:

// .../[slug]/page.tsx
import PostLayout from "./post-layout.tsx";

export default async function Page({ params }: {
  params: { slug: string }
}) {
  const content = await import(`../_content/${params.slug}/page.mdx`);

  return (
    <PostLayout frontmatter={content.frontmatter}>
      <content.default />
    </PostLayout>
  );
}

In addition to applying a layout to all pages, dynamic routing also allows us to export dynamic metadata by implementing the generateMetadata function:

// .../[slug]/page.tsx

export async function generateMetadata({ params }: {
  params: { slug: string }
}): Promise<Metadata> {
  const { frontmatter } = await import(`../_content/${params.slug}/page.mdx`);

  return {
    title: frontmatter.title
  };
};

Lastly, to preserve static generation at build time, you can implement generateStaticParams:

// .../[slug]/page.tsx

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

const blogDir = path.join(process.cwd(), "app", "blog", "_content");

export function getStaticParams() {
  const allFiles = fs.readdirSync(blogDir, { withFileTypes: true });
  const directories = files.filter((file) => file.isDirectory());
  const slugs = directories.map(({ name }) => name);

  return slugs;
}

Note: I've personally extracted the contents of getStaticParams() into another file. However, I've presented it like this for simplicity.

Conclusion

Congratulations! 🎉 Now, you've applied a layout to all of your posts, and you can access the frontmatter of that post to generate dynamic metadata. Lastly, even though we use dynamic routing, we have preserved static generation at build time.

If you have any questions, don't hesitate to ask in the comments.