Working with MDX in Next.js
Several weeks ago, I completely redesigned, rewrote, and re-engineered the Pierre Docs. What originally started as a few Notion pages had already turned into a set of low-key static pages on our logged out site, but after a slew of awesome product updates, our very manual setup was severely lacking and we simply needed more docs.
I needed an easy way to write and maintain dozens of new pages of documentation. I had just recently redone the Pierre Changelog from a similar manual setup to use MDX and dynamic routes in Next.js, and I wanted to do the same for our docs, too.
Learning new things
There’s a ton about Next.js and React that I didn’t fully know heading into this, so it took me a bit to get up to speed on a few concepts. I’m reiterating them here for my own memory and reference, but at the same time, I want to help others who might be in a similar spot. There’s no shortage of advice and posts out there, but none felt like they explained things in a helpful way for me.
Okay, so here’s a few things I needed to learn or remind myself heading into this project.
-
Files or folders with brackets in their names like
[slug]
or[slug].js
are dynamic route segments. These allow you to group files and folders of related content while generating routes for your content. In short, you can render any number of MDX pages using this page as a template. -
There’s also a special catch-all version of dynamic routes that works across nested folders by prefacing your dynamic segment with
...
. In my case with the Pierre docs,app/src/docs/[...slug]/page.tsx
is the dynamic segment that allows us to render any docs MDX page, no matter the subfolder. -
The
generateStaticParams
function works with dynamic routes to generate each static page. This function is used within those dynamic segments, and when it’s told where to look for say a bunch of MDX files in ourapp/src/docs/content/
directory, it will loop over those to statically generate each page. Neat!
Now there’s also some stuff I already knew that bears repeating:
-
MDX is a format that lets you write JSX in your Markdown documents. You can create and extend React components that can be used alongside Markdown content. Super nice for our alerts, buttons, and icons.
-
Grouping and ordering MDX files still seems like a pain in the ass, and what I mean by that is specifically around generating our docs side navigation. Our best approach here was generate the folders of static pages and use a separate JSON object to order and generate the nav.
-
There are so many ways to manage MDX, and most of them require the use of plugins to get all the functionality you really want. No issue here, but it’s worth noting that I ended up using next-mdx-remote, some Rehype plugins, and some Remark plugins. All told, it’s been great for me.
Okay with that stuff out of the way, let’s get to the good stuff—how I built the Pierre Docs using MDX and Next.js.
Implementation goals
Right before doing the Pierre docs, I had just redone the Pierre Changelog. The first iteration of it was a single page where individual entries were separate component imports and you’d link to entries with a URL hash.
It looked like this:
<div className={styles.list}>
<GithubMirror />
<BranchSummaries />
<MultiplayerEditor />
<Mentions />
<BlendedDiffs />
...
</div>
It worked for a bit, but it obviously wouldn’t go anywhere as we grow and ship more. It was already annoying after a handful of posts. What I worked up for the second iteration of the Changelog though was built for a single, flat directory. That wouldn’t suffice here.
Plus, I had some specific goals in mind for what I’d need to build.
- Must support dozens of pages
- Must support any number of nested directories
- Must be easy to contribute to by anyone on the team
- Eventually, must be abstracted to easily scale to other content directories
Okay, so with that in mind, I set out to get some help and start building.
Setting up the file structure
Our Next.js app has (what seems to me like) a fairly straightforward setup. For content that doesn’t require signing in, we use some middleware to render our logged out and marketing pages. The rough structure looks a bit like this:
pierre/
├── src/
│ ├── app/
│ │ ├── (authenticated)/
│ │ ├── changelog/
│ │ ├── docs/
│ │ ├── og/
│ │ ├── signin/
│ │ └── ...
│ ├── components/
│ ├── lib/
│ ├── primitives/
│ └── ...
├── next.config.mjs
├── package-lock.json
├── package.json
└── ...
Looking inside the docs folder, here’s what we’re working with:
docs/
├── [...slug]/
│ └── page.tsx
├── components/
├── content/
├── layout.tsx
├── page.module.css
└── page.tsx
The local (to the docs source) content folder is where we put all our MDX files. The page.tsx
file is the main component that renders the MDX content, and the [...slug]
folder is where we generate all our static pages using those fancy dynamic routes.
You could put your content elsewhere, but in the interest of limiting scope and ensuring easy access, I kept it simple and put it all in Git as local MDX files. Eventually I could see us looking into some external CMS.
Rendering MDX
Now that we have our files in place, we can start figuring out how to render the MDX into static HTML. The [...slug]/page.tsx
file is where we fetch and render the local MDX content. We use the generateStaticParams
function within to generate our static pages with next-mdx-remote
.
// app/src/docs/[...slug]/page.tsx
import fs from "node:fs";
import path from "node:path";
export const runtime = "nodejs";
export const dynamic = "force-static";
const contentSource = "src/app/docs/content";
export function generateStaticParams() {
// Recursively fetech all files in the content directory
const targets = fs.readdirSync(path.join(process.cwd(), contentSource), {
recursive: true,
});
// Declare an empty array to store the files
const files = [];
for (const target of targets) {
// If the target is a directory, skip it, otherwise add it to the files array
if (
fs
.lstatSync(
path.join(process.cwd(), contentSource, target.toString()),
)
.isDirectory()
) {
continue;
}
// Built the files array
files.push(target);
}
// Return the files array with the slug (filename without extension)
return files.map((file) => ({
slug: file.toString().replace(".mdx", "").split("/"),
}));
}
Some more things to note here that I learned along the way:
-
There are apparently two ways to work with this kind of stuff in Next.js—called runtimes—and that’s using Node or using the edge. Node is what we want since we’re statically rendering this content. The
runtime
andnode
related imports are all in support of that. Update:nodejs
is the default, so we don’t need to declare this here. Leaving it in for posterity. -
Since we want these rendered HTML pages to be static, we can force that behavior in the Next.js app directory by setting
dynamic
toforce-static
. Since we’re usinggenerateStaticParams
, this is also optional, but it’s always good to be explicit. -
Getting that list of
targets
in the source directory will include sub-directories alongside files, so we need to filter those out. That’s what theisDirectory
check handles.
The final piece of that function is to push all the files into an array and return them with the slug. We’ll use this later to help generate the pages by their slug.
Okay, now onto rendering the page! To do that, we need to build a default function for the page.tsx
file that takes the information from the magical generateStaticParams
function and renders the MDX content.
// Continuing in app/src/docs/[...slug]/page.tsx
// Add new imports
import { useMDXComponents } from "@/mdx-components";
import { compileMDX } from "next-mdx-remote/rsc";
import rehypeHighlight from "rehype-highlight";
import rehypeSlug from "rehype-slug";
import remarkGfm from "remark-gfm";
interface Params {
params: {
slug: string[];
};
}
export default async function DocsPage({ params }: Params) {
// Read the MDX file from the content source direectory
const source = fs.readFileSync(
path.join(process.cwd(), contentSource, params.slug.join("/")) + ".mdx",
"utf8",
);
// MDX accepts a list of React components
const components = useMDXComponents({});
// We compile the MDX content with the frontmatter, components, and plugins
const { content, frontmatter } = await compileMDX({
source,
options: {
mdxOptions: {
rehypePlugins: [rehypeHighlight, rehypeSlug],
remarkPlugins: [remarkGfm],
},
parseFrontmatter: true,
},
components,
});
// (Optional) Set some easy variables to assign types, because TypeScript
const pageTitle = frontmatter.title as string;
const pageDescription = frontmatter.description as string;
// Render the page
return (
<>
<h1 className={styles.pageTitle}>{pageTitle}</h1>
<p>{pageDescription}</p>
<div className={`${mdStyles.renderedMarkdown} ${styles.docsBody}`}>
{content}
</div>
</>
);
}
Okay so let’s walk through a few things…
We’re importing a few new things—the useMDXComponents
function and a few plugins for our MDX content. We’re also importing the compileMDX
function from next-mdx-remote/rsc
to render the MDX content.
Here’s a simplified version of the src/mdx-components.ts
file:
// src/mdx-components.ts
import type { MDXComponents } from "mdx/types";
import Image, { ImageProps } from "next/image";
// other imports...
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
a: ({ children, href }) => (
<a href={href} className="styledLink">
{children}
</a>
),
hr: (props: React.ComponentProps<typeof Divider>) => (
<Divider
style={{ width: 120, marginTop: 40, marginBottom: 32 }}
{...props}
/>
),
img: (props) => (
<Image
style={{ maxWidth: "100%", height: "auto" }}
{...(props as ImageProps)}
/>
),
// etc
...components,
};
}
If you’re new to using MDX components, this basically let’s you override default HTML elements rendered by MDX and make your own components available to use in .mdx
files. For example, in the above snippet we change all default anchor elements to have the class styledLink
. We also override the default horizontal rule to use our Divider
component.
Back to the rest of the page…
Our components and the MDX plugins that we want are passed to the compileMDX
function to render the content. Those are straightforward, so I won’t get into them. We also parse the frontmatter from the MDX file to get the page title and description. We also use fetch a few other things from our page frontmatter—icon, color, OG image, etc—but we won’t get into that here. Lastly, we put it all together at the end in the return
to build the page’s HTML.
For the Markdown styling, we have a stylesheet that we made for our file explorer when we added rendered Markdown support. That’s the mdStyles.renderedMarkdown
part. There wasn’t much to tweak here either since everything else fell into customizing MDX components.
From here, we have MDX pages that can be dynamically rendered at any level of the source directory, with easy frontmatter access and custom components, plus a set of functions that can be easily extended into other areas of the site.
Sidenav
One bummer about directories of Markdown/MDX has always been building navigation for those pages—seemingly no matter the framework or language. Setting the order, adding icons, controlling states—woof. We could use fs
to read from the directory and build a nav ourselves, but how would we order a dynamic set of pages within each sub-directory? Frontmatter could maybe help, but then you’re stuck updating values across multiple pages.
The easiest solution is still building a single config file of sorts for the navigation using JSON or YML. We took that route with the docs pages—here’s a snippet of it.
// src/app/docs/Nav.tsx
export const DocsNav = [
{
header: "Getting Started",
icon: "IconBook",
color: "purple",
items: [
{ href: "/docs/getting-started", label: "Overview" },
{ href: "/docs/getting-started/new", label: "Create a New Workspace" },
{
href: "/docs/getting-started/join",
label: "Join an Existing Workspace",
},
{ href: "/docs/getting-started/import-code", label: "Import Code" },
{ href: "/docs/getting-started/ssh", label: "Setup SSH" },
],
},
{
header: "Workspaces",
icon: "IconBuilding",
color: "blue",
items: [
{ href: "/docs/workspaces", label: "Overview" },
{ href: "/docs/workspaces/navigation", label: "Navigation" },
{ href: "/docs/workspaces/members", label: "Members" },
{ href: "/docs/workspaces/repositories", label: "Repositories" },
{ href: "/docs/workspaces/notifications", label: "Notifications" },
{ href: "/docs/workspaces/presence", label: "Presence" },
{ href: "/docs/workspaces/settings", label: "Settings" },
],
},
// ...
];
Elsewhere, we use that JSON to build our sidebar as its own component that can then be rendered wherever we want in our docs layout. Here’s a look at how we built that with our own components:
import { Colors } from "@/primitives/Color";
import { Column } from "@/primitives/Layout";
import * as Icons from "@/primitives/icons";
import { createElement } from "react";
import { DocsNav } from "./Nav";
import { NavHeader } from "./NavHeader";
import { NavItem } from "./NavItem";
export const DocsMenu = () => {
return (
{DocsNav.map((menuItem) => (
<NavHeader
key={menuItem.header}
title={menuItem.header}
color={menuItem.color as keyof typeof Colors}
icon={createElement(Icons[menuItem.icon as keyof typeof Icons])}
items={menuItem.items}
>
<Column gap={1} style={{ width: "100%" }}>
{menuItem.items.map((item) => (
<NavItem href={item.href} key={item.label}>
{item.label}
</NavItem>
))}
</Column>
</NavHeader>
))}
);
};
We used the same approach overall with our docs homepage, except we created a separate JSON blurb and map
since we only wanted to show a subset of pages that we hand-selected. This gave us a bit more flexibility, but it’s also something we could maybe improve down the line as well to avoid the repetition.
Up Next: Abstraction
The last goal for me was to be able to abstract all of this so I can use it elsewhere—like on the Changelog or in the Styleguide I’ve been (very) slowly chipping away at. I’ll save that for another post as I’m still working on that part.
Suffice to say it has also felt relatively straightforward to do and I love that I’m able to get this stuff working elsewhere with ease now.
See you again for the next part!