Jun 3, 2024

Working with MDX in Next.js

Top Front-end Bloggers All from @mdo View Working with MDX in Next.js on markdotto.com

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.

Pierre Docs home

The homepage for the new Pierre Documentation.

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.

Now there’s also some stuff I already knew that bears repeating:

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.

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:

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={</span><span class="p">${</span><span class="nx">mdStyles</span><span class="p">.</span><span class="nx">renderedMarkdown</span><span class="p">}</span><span class="s2"> </span><span class="p">${</span><span class="nx">styles</span><span class="p">.</span><span class="nx">docsBody</span><span class="p">}</span><span class="s2">}> {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= {...props} /> ), img: (props) => ( <Image style= {...(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=> {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!

Scroll to top