Next.js + MDX

A guide to using Next.js and MDX to render markdown content with custom components from a remote source

Guides ->

6-8 minute read

Table of Contents

Next.js Setup

First, we need to get a project set up, otherwise we won't have anything to add mdx content to.

If you already have your own Next.js project, you can skip ahead to the MDX Setup.

If not, the easiest way to get a Next.js project off the ground is using the create-next-app npm package, which scaffolds an entire project for you. You can execute the package using your favourite Node.js package manager (such as npm, yarn, or pnpm)1. Alternatively, you can also use ni2, which is what I'll use for this guide to avoid confusion. Choose a package manager and you will be good to go.

nx create-next-app --ts next-mdx-project

Next, navigate to the created project.

cd next-mdx-project/

Now start the development server and go to http://localhost:3000 to see the project live in action.

nr dev

MDX Setup

MDX is a language that builds on the standard Commonmark syntax by adding the capability to use custom components inside your markdown. These components can be React components (mdx-js/react), Vue components (mdx-js/vue), or even Svelte components (MDsveX)

There are two main methods of using MDX in a Next.js project:

  1. @next/mdx, the official MDX plugin for Next.js
  2. next-mdx-remote, a community package maintained by Hashicorp that allows you to use MDX from outside your project.

While @next/mdx is a wonderful package, this guide uses next-mdx-remote for more flexibility.

ni next-mdx-remote

Creating Content

You'll need a data source to pull your content from. Since next-mdx-remote only needs to read a text file in order to work, there are many options to choose from. For example, this source can be a folder in your project3, another GitHub repository4, or an external service.

This guide will use a local directory called content/ inside the project:

.
├── content/
├── public/
├── src/
├── package.json
└── next.config.js

Fetching Content

We can split the logic into four different files:

src/
├── components/
│   ├── MDX.tsx
├── lib/
│   ├── fs.ts
│   └── mdx.ts
└── pages/
    └── [...path].tsx

In MDX.ts, we wrap the existing MDXRemote component so that we can add custom components globally. This is not necessary depending on your requirements, so feel free to skip this step if you don't need it.

// components/MDX.tsx
import { MDXRemote } from 'next-mdx-remote'

import type { MDXContent } from 'lib/mdx'

export type MDXProps = {
  content: MDXContent
  components?: Record<string, React.ReactNode>
}

export const MDX: React.VFC<MDXProps> = ({ content, components }) => (
  <MDXRemote
    {...content}
    components={{
      // add global custom components above this line
      ...components,
    }}
  />
)

In fs.ts, we will work closely with the Node.js standard library file system module

We will fetch the file paths using the readdir function and fetch the contents of each file using the readFile function.

Note: basePath and path are arrays because that makes them a lot easier to work with when adding and removing elements.

// lib/fs.ts
import { readFile, readdir } from 'fs/promises'

export const fetchFile = async ({
  basePath,
  path,
  extension,
}: {
  basePath: string[]
  path: string[]
  extension?: string
}) => {
  const fullPath = [...basePath, ...path].join('/')

  const fullFilePath = [fullPath, extension].join('.')

  const file = await readFile(fullFilePath, 'utf-8')

  return file
}

export const fetchPaths = async ({
  basePath,
  path,
  extension,
}: {
  basePath: string[]
  path: string[]
  extension?: string
}) => {
  const files = await readdir(
    [process.cwd(), ...basePath, ...path].join('/'),
    'utf-8'
  )

  if (extension) {
    return files
      .filter((file) => file.includes(extension))
      .map((file) => {
        const [slug] = file.split('.')

        return [...path, slug!]
      })
  }

  return files.map((file) => {
    const [slug] = file.split('.')

    return [...path, slug!]
  })
}

With mdx.ts, we are creating a wrapper function around next-mdx-remote's serialize function, where we can add remark and rehype plugins if we want. We will also provide more transparent typing.

// lib/mdx.ts
import { serialize } from 'next-mdx-remote/serialize'

export type MDXContent = {
  compiledSource: string
  frontmatter?: Record<string, string>
}

export const fetchMDXContent = async (file: string): Promise<MDXContent> =>
  await serialize(file, {
    mdxOptions: {
      remarkPlugins: [
        // optional plugins
      ],
      rehypePlugins: [
        // optional plugins
      ],
    },
  })

Lastly, in [path].tsx, it all comes together. We pull in all the modules we have created so far, and use them here.

We begin by registering the file paths we collected in Next.js. We do this by calling the special function getStaticPaths.

Next, based on the current file path, we fetch the respective content of the file. Similarly to getStaticPaths, there exists a special function getStaticProps for this as well.

Last, we pull in the content as a prop in the main page component and render it using our MDX component.

// pages/[path].tsx
import { MDX } from 'components/MDX'

import { fetchMDXContent } from 'lib/mdx'
import { fetchFile, fetchPaths } from 'lib/fs'

import type { NextPage, GetStaticPaths, GetStaticProps } from 'next'
import type { MDXContent } from 'lib/mdx'

type PageProps = {
  content: MDXContent
}

const basePath = ['content'] // path to the content directory
const extension = 'mdx'

export const getStaticPaths: GetStaticPaths = async () => {
  const paths = (
    await fetchPaths({
      basePath,
      path: [],
      extension: 'mdx',
    })
  ).map((path) => ({
    params: {
      path,
    },
  }))

  return {
    paths,
    fallback: false,
  }
}

export const getStaticProps: GetStaticProps<PageProps> = async ({ params }) => {
  if (!params) {
    return {
      notFound: true,
    }
  }

  const { path } = params

  if (!Array.isArray(path)) {
    return {
      notFound: true,
    }
  }

  const file = await fetchFile({
    basePath,
    path,
    extension,
  })

  /* for frontmatter
  const { frontmatter } = fetchMDXContent(file)
  */

  const props = {
    content: await fetchMDXContent(file),
    // ...frontmatter
  }

  return { props }
}

const MDXPage: NextPage<PageProps> = ({ content }) => (
  <main>
    <article>
      <MDX content={content} />
    </article>
  </main>
)

export default MDXPage

Footnotes

  1. I highly recommend pnpm — it's faster than yarn, and way faster than npm. Also, your node_modules/ will be much more organised with pnpm, since only the packages defined in your package.json will be included in the root of the directory.

  2. Anthony Fu has created a tool called ni that makes choosing the correct node.js package manager much easier. If you are inside an existing project that contains a lockfile, it will automatically use the corresponding package manager (npm, yarn, or pnpm). If no lockfile is found, you will have the option to choose which package manager you'd like to use. If you don't have ni installed, you can use npm, yarn, or pnpm directly, or you can install it via one of them.

  3. A common approach is storing your content in a folder in the root directory of your project. Often this folder is called something along the lines of content/ or data/.

  4. Usually the markdown files are committed to the external repository—an approach often favoured by documentation sites. However, you might want to also look into using GitHub issues as a CMS, which swyx does for his site.

Last Updated: 1649470068000

Edit on GitHub ->