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.