Blogging with MDX in Next.js
I've always been a fan of authoring content for my blog using markdown. It's concise yet flexible enough for most of my needs, while also having the advantage of compatibility with version control systems. When I reworked my website last year, I tried to leverage a content management system, and found that it was simply more than I needed to publish content regularly. Thus, with this year's rework, I wanted to return to authoring content inline in my site's repository using markdown once again.
The Approaches
There were three main choices through which I could accomplish this. These were
contentlayer, next-mdx-remote, and
@next/mdx. Both contentlayer
and
next-mdx-remote
offer frontmatter support, and contentlayer
provides a complete content database abstraction.
This would need to be built with the next-mdx-remote
library, as well as the official @next/mdx
library. The
official @next/mdx
library enables you to author pages within the AppRouter's app directory directly in markdown,
and provides the best support for other React components built within the Next.js application.
Unfortunately, contentlayer
does not yet support Next.js 14, which was a key requirement for my rework, so it isn't
viable for my purposes, even though it would be a strong contender otherwise. This left the need to build a content
database regardless of which remaining option I chose. For this reason, the convenience of the official @next/mdx
library felt like it offered more benefit.
Setting Up
To begin, you'll need to set up the Next MDX library according to the directions in the Next.js documentation. Note that you must create the mdx-components.tsx file in the root of your project as described in order to get MDX support to work properly, even if you have no additional components to add or remap.
Complete details can be found here.
Authoring Posts
Our posts will live in the src/app/posts
folder in our application. When we want to add a new post, we will create
a new subfolder named with the post's slug. Inside of this directory, we will add a page.mdx
file that will
contain the content and metadata for our post.
Before we can create our post page, we need to have a few things in place first.
The Post Model
To represent information about the post, we need to define a model for the metadata that can be used by a common layout
to display details about the post in addition to the content. Fields that might be useful as a baseline could include
title
, description
, author
, and postedAt
.
export interface PostModel {
title: string
description: string
author: string
postedAt: Date
}
export interface Post extends PostModel {
slug: string
}
Note that we don't define the slug in the Post Model, as the slug will be derived from the path containing the
page.mdx
file. For the sake of completeness, we define a Post that includes the slug, as this will be returned from our content database when we need to query for Posts.
We don't need to define the content as a part of the post, since this information is used to represent and display post database information only, and content will never be required from the post database.
The Post View
To prevent duplication of effort, we need a way to combine the post database information with the content information
in the page.mdx
file. It may not be immediately obvious, but the page.mdx
file's markdown content is the
default export of the file, unless we override it. If we override it, we receive a PropsWithChildren
containing
the markdown content as the children
property, and we can feed this into another component to handle the layout.
We'll define that here.
import { PropsWithChildren } from "react"
import { PostModel } from "@/features/posts"
export type PostViewProps = PropsWithChildren<{
post: PostModel
}>
export default function Post(props: PostViewProps) {
return (
<article>
<header>
<h1>{props.post.title}</h1>
<time dateTime={props.post.postedAt.toISOString()}>
<span>
{props.post.postedAt.toLocaleDateString("en-US", {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
})}
</span>
</time>
</header>
<div>{props.children}</div>
</article>
)
}
The Post Page
With these components in place, we can author our first piece of content. Here's a look at how to create a page that
adds database information for itself, and uses the layout we defined. The following content would be added to the
src/app/posts/my-first-post/page.mdx
file to use the my-first-post
slug for the post.
import Post from "@/components/layouts/Post"
export const post = {
title: "My First Post",
description: "My First Post.",
author: "Jonah Grimes",
postedAt: new Date("2024-01-06T00:00:00Z"),
}
export const metadata = {
title: post.title,
description: post.description,
}
export default (props) => <Post post={post} {...props} />
My first post has this content.
Posts Database
Now that we have an effective way of authoring post content, adding consistent metadata for the post database, and leveraging a common layout for posts that incorporates these elements, we turn our attention to the real challenge at hand: how do we acquire a list of these posts to use in post index pages and recent post type features of our website?
While I won't go into the details of implementing the index and post pages themselves, I will discuss a simple approach
for scanning the posts directories and importing the model data for sorting and filtering. We'll do this with the help
of the fast-glob
package.
import glob from "fast-glob"
export default class Posts {
static async findAll(): Promise<Post[]> {
const postFilenames = await glob("*/page.mdx", {
cwd: "./src/app/posts",
})
const posts = await Promise.all(postFilenames.map(this.importPost))
return posts.sort((a, z) => +z.postedAt - +a.postedAt)
}
private static async importPost(postFilename: string): Promise<Post> {
const slug = postFilename.replace(/(\/page)?\.mdx$/, "")
return import(`../app/posts/${postFilename}`)
.then((postModule) => postModule.post as PostModel)
.then((postModel) => ({
slug,
...postModel,
}))
}
}
This allows us to acquire all posts, sorted from newest to oldest, for use in our index pages, or to implement features such as latest posts, or featured posts within our landing pages.
import Posts from "@/features/posts"
export default async function Page() {
const posts = await Posts.findAll()
// return ui to render posts index.
}
Conclusion
Hopefully this helps you if you're interested in working with the latest version of Next.js and need to produce content, but don't yet feel like a full blown content management system will benefit your workflow. For these simple use cases, working with the official paths for MDX support in Next.js provides a pretty convenient option, and as I've demonstrated here, the drawbacks are fairly easy to overcome with a little bit of Typescript and elbow grease.
As always, if you've benefited from this information, and you'd like to reach out, or you have a suggestion for how I can make this solution even better, please feel free to drop me a line at jonah@nerdynarwhal.com. Thanks for reading!