MDX in Next.js

MDX is one of, if not, the most popular ways to manage content as a developer, but configuring an app to use it can prove to be a challenge. Let’s see how we can set up MDX in a Next.js App Router app.

Table of Contents

YouTube Preview
View on YouTube

What is MDX?

MDX is a content format that combines Markdown with JSX.

With it, you can use the Markdown format, which is a lightweight syntax for writing content for the web, along React components (or other JSX) to produce rich, dynamic content.

What is the challenge with MDX in Next.js?

It’s not a challenge per se, but it doesn’t work out-of-the-box, meaning we need to configure it to work if we want to use MDX.

Luckily configuration isn’t too bad, it even comes with a first-class package to help support it, but we’ll see where that falls short and another option we can use for our MDX needs.

Step 0: Creating a Next.js app

Getting started, you’ll want to be working out of a Next.js project. You can use any Next.js project, but I’ll be walking through using the App Router, so configuration will be a little different if you’re in the Pages router.

You can start from scratch or use an existing project, but a prerequisite is having some MDX content to test this with, but you can simply create a new MDX file or two and use that as your test basis.

Alternatively, you can grab some of the files I’m using in my example.

If starting from scratch, in your terminal run:

npx create-next-app@latest

Then follow along with the options of your choice and finally, start adding some MDX content.

Step 1: Sourcing MDX content in Next.js

The first step we’ll take in sourcing MDX content is by using the official Next.js MDX package.

This method uses @next/mdx along with official MDX JS packages @mdx-js/loader and @mdx-js/react to render MDX content in Next.js.

Installing MDX Dependencies

First install the dependencies:

npm install @next/mdx @mdx-js/loader @mdx-js/react

We’ll then need to do two things inside of our next.config.js file:

  • Telling Next.js that we want to support additional page file extensions
  • Wrapping our configuration object with the MDX module

Inside of next.config.js add:

const withMDX = require('@next/mdx');

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
};

module.exports = withMDX()(nextConfig);

And then we need to add a boilerplate file that will allow us to configure custom components in the future.

Create a new file mdx-components.tsx inside of your src directory or in the root of your project if not using src (adjacent to your app directory) and inside add:

import type { MDXComponents } from 'mdx/types'

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return { ...components };
}

And from here, this is all you need to configure MDX, next is prepping the MDX content to run through the Next.js pipeline.

Rendering MDX Content

The trick here though is that to use the official Next.js MDX package, our content needs to be located inside of the app directory, just like any other page file.

Similarly, the file pattern will also be just like any other page route, where we need to use the <name>/page.mdx pattern to create a new MDX-driven route.

So for instance, if you have a content directory with files like mycontent.mdx, you’ll need to move that file into the app directory such as app/mycontent/page.mdx.

MDX files inside of app directory

Tip: Before you start renaming all of your files, check out the next Step which uses a different method allowing you to keep your files in the same spot!

After updating the file names and locations, we should now be able to go to that page just like any other page route, and see our markdown rendered in the browser!

Browser showing MDX content rendered

But wait, we might notice an issue depending on how our MDX is formatted. Our frontmatter is showing at the top of the page, that’s not right.

Tip: What’s frontmatter? Frontmatter is metadata that can be placed at the top of an MDX file that can be used as structured data.

While Next.js has a few options they mention you can use to solve this, none are first class, and are a bit involved.

Alternatively, we can use a different approach and at the same time, solve the issue of having to follow the Next.js page filename convention to render MDX.

Tip: If you’re happy with the results of this first step, you can probably just stop here! Otherwise, if you want to load MDX from a different directory and support frontmatter, continue on.

Follow along with the commit!

Step 2: Rendering MDX with Next MDX Remote

Another option to source MDX content in Next.js is Next MDX Remote.

This package intends to solve a slightly different use case of “remote” content, which could be remote as in outside of the app directory or literally from a remote location, such as requesting it from a server.

But in our example, say we have a content directory in the root of our project with a dump of our MDX, and it’d be tedious, and frankly a bit overwhelming to put that all inside of the app directory with the page filename pattern, we can instead use Next MDX Remote to render our content from where it is.

Reverting Step 1

To get started, we can undo some of our changes from the previous step, including:

  • Removing or moving the content MDX content from inside of the app directory to an outside folder
  • Removing the mdx-components.tsx file
  • Reverting the next.config.js changes
  • Uninstalling @next/mdx @mdx-js/loader @mdx-js/react

Installing Next MDX Remote

Next let’s install Next MDX Remote:

npm install next-mdx-remote

Setting Up Dynamic Page Routes

To create our dynamic pages, we’re going to use dynamic routes, much like if we were using an API to generate content from a CMS.

For my example, my MDX files describe a list of projects, so I want each project available at /projects/<name>, so I’ll be using “projects” as my identifier and grouping. If you for instance are creating a blog, you might use posts instead.

Inside of the app directory, create a new folder where you want your page to live, such as /projects.

Inside app/projects create an additional folder [projectSlug] and inside create a new file page.tsx.

By wrapping projectSlug in brackets in the folder name ([projectSlug]), we’re telling Next.js we want that to be a dynamic route.

Next, inside app/projects/[projectSlug]/page.tsx let’s set up a basic page:

export default async function ProjectPage() {
  return (
    <div>Content</div>
  )
}

Note: Notice the async which will be important for loading our content into the page.

If we now try to go to our page in the browser, such as in my case http://localhost:3000/projects/space-invaders, you should get an empty page with the word Content inside.

Next let’s render our MDX.

Sourcing MDX Content with Next MDX Remote

To source the content, we’re going to use Next MDX Remote along with the fs and path modules to grab our page content and structure our file paths.

First, import Next MDX Remote along with fs and path:

import { MDXRemote } from 'next-mdx-remote/rsc'
import { promises as fs } from 'fs';
import path from 'path';

We’re importing the MDXRemote component from the rsc subpath. We’re also importing the promises version of fs so we can use async/await.

Then we want to grab our mdx file content from our local directory.

First, we need to obtain our dynamic page parameters. Update the page component to the following:

export default async function ProjectPage({ params }: { params: { projectSlug: string }}) {

Then at the top of your page component, add:

const content = await fs.readFile(path.join(process.cwd(), 'src/projects', `${params.projectSlug}.mdx`), 'utf-8');

Here w’ere using the readFile method which does what it sounds like. We’re also using path to piece together the different dynamic segments of our file path. We use process.cwd() in order to get the “current working directory” from where our project is running, so that we have a way to confidently specify where our files are at.

Note: Update src/projects along with any other values to point to the location of where you’re storing your files.

Then to render our content, we’re going to simply pass it to our MDXRemote component where we’re rendering our content:

import { MDXRemote } from 'next-mdx-remote/rsc'
import { promises as fs } from 'fs';
import path from 'path';

export default async function ProjectPage({ params }: { params: { projectSlug: string }}) {
  const content = await fs.readFile(path.join(process.cwd(), 'src/projects', `${params.projectSlug}.mdx`), 'utf-8');
  return (
    <div>  
      <MDXRemote source={content} />
    </div>
  )
}

Now if you have basic markdown in your files, you might be able to spin up your page in the browser and it should render! But if you’re like me, i’m using components inside my MDX, so my page currently returns an error.

MDX Content error

With Next MDX Remote, we need to define the components that will be used and imported, unlike the standard Next.js way from Step 1, but that’s easy, we just need to attach them to the MDXRemote component, where in my case, I’m using a few different types of components:

<MDXRemote
  source={content}
  components={{
    // Your Components here
  }}
/>

But now, you should be able to load the page and see your MDX content rendered!

MDX content rendered in browser

Except we have an issue, we’re still not rendering our frontmatter.

While the frontmatter isn’t rendered by default, now that we’re hooked into Next Remote MDX, we can take advantage of their APIs to parse the frontmatter and render the rest of the content.

Follow along with the commit!

Step 3: Parsing frontmatter in MDX

In order to parse our frontmatter, we’re going to use a different function from the Next Remote MDX package.

But not to worry, it’s basically the same API, only we’re using a function instead of a component.

To start, let’s update our import statement:

import { compileMDX } from 'next-mdx-remote/rsc'

Next, because we’re using a function, we’re going to parse our frontmatter and content before our return statement, so under the line where we’re reading our file into the content variable, add:

interface Frontmatter {
  title: string;
}

const data = await compileMDX<Frontmatter>({
  source: content,
  options: {
    parseFrontmatter: true
  },
  components: {
    // Your Components here
  }
})

Note: Be sure to update the types to match your frontmatter!

Then we can replace the MDXRemote component with our parsed frontmatter and content:

return (
  <div>  
    { data.content }
  </div>
)

Where depending on your use case, you may not even need the frontmatter in your rendered web view. For example, my MDX documents include an H1, so I don’t need to use the frontmatter title for the H1. But in case you do, it will be available at data.frontmatter, such as:

<h1>{ data.frontmatter.title }</h1>

But with that, we should now see our content properly rendered on the page!

MDX rendered without extra frontmatter

Follow along with the commit!

Step 4: Listing MDX Files from a Local Directory

Now that we have our dynamic pages set up, we need a way to route people to those pages.

One way is to simply list out all of our files, similar to what you might find on a blog homepage.

To do that, we’ll use a similar method to how we read our local files, only this time, we’ll read the directory, then read our file contents, so we have something to display.

Starting off, wherever you’re listing your files (ex: /projects), let’s first import the dependencies:

import { promises as fs } from 'fs'
import path from 'path';
import { compileMDX } from 'next-mdx-remote/rsc';

Next, we want to read the filenames from our local directory, which we can use the readdir method of fs:

const filenames = await fs.readdir(path.join(process.cwd(), 'src/projects'))

Note: Reminder to update your file path!

If you log this out, this will be a list of filenames from your directory (ex: space-invaders.mdx).

Now that we have that list, we can grab the content of each one like we did on our dynamic page route to get the metadata we need to create a user-friendly list.

interface Frontmatter {
  title: string;
}

const projects = await Promise.all(filenames.map(async (filename) => {
  const content = await fs.readFile(path.join(process.cwd(), 'src/projects', filename), 'utf-8');
  const { frontmatter } = await compileMDX<Frontmatter>({
    source: content,
    options: {
      parseFrontmatter: true
    }
  })
  return {
    filename,
    slug: filename.replace('.mdx', ''),
    ...frontmatter
  }
}))

Here we’re looping through each file name, reading the file, and performing the same compileMDX function as before, only this time, we only need the frontmatter, so we return that, along with the filename in case we need it later, and importantly we’re returning a “slug” version of the filename without the file extension which is how we’ll create our dynamic page path.

All’s left now is to take our list and add links to our page:

<ul>
  {projects.map(({ title, slug }) => {
    return (
      <li>
        <Link href={`/projects/${slug}`}>{ title }</Link>
      </li>
    )
  })}
</ul>

Here we’re passing in a dynamic path to our Link element which will allow us to point to our individual pages.

And now, you should see a list of links when opening the page in your browser!

List of links pointing to dynamic pages

Follow along with the commit!

What else can we do?

Generate SEO Metadata

An important part of dynamic pages is being able to dynamically generate SEO metdata, including the title, description, and even Open Graph images.

We can export and use the generateMetadata function and similarly parse the frontmatter with Next Remote MDX to populate all of the meta tags we need!