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!