Updated
Markdown in Next.js
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:
- Readability: Its plain text that is easy to read
- Simple: Minimal syntax, can be edited with any text editor and low barrier to entry
- Portability: Can be used across different platforms and tools, like Obsidian
- Version Control Friendly: Works well with Git, diffs are readable and resolving merge conflicts is easy
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:
- A Heading component that automatically adds anchor links
- A Code component with a copy to clipboard button
- A expand/collapse component for optional content
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.
- Ease of Use: The solution should be easy to set up and maintain.
- Performance: The solution should be performant and not add significant overhead to the application, particularly because I use static site generation for my blog.
- Maintained: The libraries should be actively maintained and have a good community around them. For this I look at the GitHub repository for recent activity, open issues, and pull requests. For community, I check the download stats on NPM.
- Minimal Dependencies: This comes hand in hand with being maintained, the more dependencies the more that needs to be updated and maintained.
- YAML Frontmatter Support: YAML frontmatter is the de-factor standard for metadata in Markdown files, so the solution should support it out of the box.
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
@next/mdx
on NPM- Weekly Downloads: ~330,000
- Last Published: 1 day ago
- 1 dependency, though requires a couple MDX dependencies to be installed separately
- Supports Next.js file based routing, e.g.
/app/posts/post-a/page.mdx
- Has experimental support for a rust-based MDX parser, however its marked as experimental (and has been for a while now)
Limitations
- No built-in support for YAML frontmatter, it does support metadata as a JS object though
- Primarily built for use with file based routing (e.g.
/app/posts/post-a/page.mdx
), it does support dynamic imports but its not very smooth when I've tried
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
@content-collections/next
and@content-collections/core
on NPM- Weekly Downloads: ~16,000 (for the
next
package) - Last Published: 1 month ago
- ~13 dependencies across the two packages
- Weekly Downloads: ~16,000 (for the
- Handles defining the content schemas, reading the files, parsing the frontmatter and content, and rendering the MDX
Limitations
- Builds as a separate process outside of Next.js, the build process uses esbuild to compile the content, it feels a bit clunky to require a separate bundler
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
next-mdx-remote-client
on NPM- Weekly Downloads: ~50,000 weekly downloads
- Last Published: 6 days ago
- 7 dependencies, though
@mdx-js/react
and@mdx-js/loader
is included.
- Clean set of APIs for loading and rendering MDX content
Limitations
- Documentation is a little sparse, mainly missing a simple "getting started" section to show the basic usage of the library, but the code examples in the repo are good.
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
- Using React
cache
- Using Next.js's
"use cache";
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.