Updated

Markdown in Next.js

Next.jsMarkdownMDX

I've been exploring the best way to handle Markdown content in Next.js applications, with my primary use case being the posts on this blog.

Next.js has a lot of options for handling Markdown, so I wanted to do a deep dive into the options available.

Markdown and MDX

I've been standardizing on Markdown for my written content for a few reasons, namely:

The most popular way to use Markdown in the React ecosystem is MDX, which is a extension of Markdown that allows JSX components inside Markdown files. In practice, this lets you leverage existing React components in your Markdown content, for example:

MDX also has really good developer tooling and documentation around it, which is an added benefit even if you don't use JSX components.

Tech Selection Requirements

I wanted to research and identify the best set of libraries to use with Next.js, to feed that Tech Selection I wanted to collate my requirements for the solution.

File Structure

I want my content to be separate from my application code for a clean separation of concerns.

Here is my ideal file structure:

repo/
├── posts/
│   ├── my-first-post.mdx
│   └── my-second-post.mdx
└── src/
    └── app/
        └── posts/
            ├── page.tsx <-- Displays a list of posts
            └── [slug]/
                └── page.tsx <-- Renders an individual post

Options

I've done a deep dive into what the options are for Next.js and here are the main options I've found.

@next/mdx

@next/mdx is the official MDX integration for Next.js maintained by the Next.js team, which is why I started my research here.

Being the official library, it is well maintained and has good documentation, however I've found a couple pain points:

Features

Limitations

Content Collections

Content Collections is a relatively new library for managing content in Next.js applications. Its a different class of library to the other options, as it provides a full content management solution rather than just MDX rendering.

Conceptually its similar to Astro's Content Collections and the unmaintained Contentlayer.

Features

Limitations

micromark

I briefly looked at using a low level Markdown parser like micromark, to see if its more suitable then a MDX-based solution. Its quick to get going, but misses the ability to leverage existing React components in the Markdown content.

As a result I didn't explore it too much.

next-mdx-remote-client

next-mdx-remote-client is a fork of next-mdx-remote that supports app router. It also supports YAML frontmatter out of the box, which is a nice bonus.

Features

Limitations

Installation

Installing the library is straightforward:

bun add next-mdx-remote-client

Code Example

First create a lib/posts.ts file to handle loading and parsing the posts.

// lib/posts.ts
import { cache } from "react";
import { readdir, readFile } from "fs/promises";
import { getFrontmatter } from "next-mdx-remote-client/utils";
import { z } from "zod";
 
/**
 * The directory where the MDX files are kept
 */
const CONTENT_DIR = "./posts";
 
const PostSchema = z.object({
  slug: z.string(),
  title: z.string(),
  date: z.string().transform((str) => new Date(str)),
  description: z.string().optional(),
  source: z.string()
});
 
/**
 * Cache the list of posts to improve performance
 */
export async function listPosts() {
  "use cache";
  const files = await readdir(CONTENT_DIR);
  return await Promise.all(
    files
      .filter((file) => file.endsWith(".mdx")) // Filter out non-MDX files
      .map(file => getPost(file))
  );
};
 
async function getPost(filename: string) {
  const filePath = `${CONTENT_DIR}/${filename}`;
  const source = await readFile(filePath, "utf-8");
  const { frontmatter, strippedSource } = getFrontmatter(source);
  return PostSchema.parse({
    ...frontmatter,
    slug: filename.replace(/\.mdx?$/, ""), // Remove the file extension for the slug
    source: strippedSource,
  });
}

Then create the page to list all the posts, using the listPosts function from above.

// app/posts/page.tsx
import { listPosts } from "@/lib/posts";
 
export default async function Page() {
  const posts = await listPosts();
 
  // Sort posts by date
  posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
 
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.slug}>
          <a href={`/posts/${post.slug}`}>{post.title}</a>
          <time dateTime={new Date(post.date).toISOString()}>
            {new Date(post.date).toLocaleDateString()}
          </time>
        </li>
      ))}
    </ul>
  );
}

Finally create the dynamic route to render an individual post.

// app/posts/[slug]/page.tsx
import { notFound } from "next/navigation";
import { MDXRemote, type MDXComponents } from "next-mdx-remote-client/rsc";
import { listPosts } from "@/lib/posts";
 
// Add any custom components here
const mdxComponents: MDXComponents = {};
 
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
 
  // `listPosts` is cached, so this is fast
  const allPosts = await listPosts();
  const post = allPosts.find((p) => p.slug === slug);
  if (!post) {
    notFound();
  }
 
  return (
    <article>
      {/* An example blog header */}
      <h1>{post.title}</h1>
      <time dateTime={post.date.toISOString()}>
        {post.date.toLocaleDateString()}
      </time>
 
      <MDXRemote
        source={post.source}
        components={mdxComponents}
      />
    </article>
  );
 
}

Conclusion

I ended up using next-mdx-remote-client for my blog, as it met all my requirements and was easy to set up and use.

Performance Benchmark

I did a quick benchmark of the build times with a large number of posts (~1000).

I tried 3 approaches:

No caching

Without caching, the build doesn't complete. From doing a quick search online, seems like its related to the amount of file descriptors available on macOS.

[Error: ENFILE: file table overflow, open '.../node_modules/next/dist/server/node-environment-extensions/random.js'] {
  errno: -23,
  code: 'ENFILE',
  syscall: 'open',
  path: '.../node_modules/next/dist/server/node-environment-extensions/random.js'
}

Using React cache

Doesn't seem to reuse cached values, if I put a console.log in the listPosts function, it prints many times during a build.

bun run build  127.61s user 55.15s system 577% cpu 31.634 total

Using use cache (Next.js)

Seems to reuse cached values, if I put a console.log in the listPosts function, it only prints a handful of times (around 10) during a build.

bun run build  44.55s user 6.72s system 369% cpu 13.893 total

Content Collections

I also quickly tested against Content Collections, which was what I previously used.

bun run build  30.12s user 7.05s system 226% cpu 16.438 total

Results

The use cache based approach was the fastest of the approaches I tried, including Content Collections. This makes sense logically, as Content Collections is running a additional esbuild process, hence why it would be slightly slower.