Making a Table of Contents for mdx posts with Next.js

Last updated: 14 August 2022

Next.js is a web development framework that supports React components, folder based routing, static page rendering, and server-side requests, to name a few. I recently made my personal website using Next.js for the first time.

MDX is an extension of markdown, a terse markup language for html, but extends support for file imports, exports, and jsx (e.g. for incorporating React components).

After reading this great blog post by Josh Comeau, I decided I would try using mdx with with Next.js to build my personal blog. I then went of a bit of an adventure trying different packages and strategies.

mdx rendering options in Next.js

There are several packages for incorporating mdx files into Next.js including:

  • The official @next/mdx
  • Hashicorp's next-mdx-remote
  • Hashicorp's next-mdx-enhanced
  • Kent C Dodd's mdx-bundler

See this post by Tyler Smith for an overview of the strengths and weaknesses of each approach.

I ended up flip-flopping between @next/mdx and next-mdx-remote but ultimately chose @next/mdx after a long journey that centered around building a Table of Contents like you see in the full-screen version of this page. At one point I even had my code base supporting @next/mdx and next-mdx-remote for different parts of my website, which shows they do not conflict.

My summary of the two

@next/mdxnext-mdx-remote
Export content as React components
Offical support from Next.js (Vercel)
.mdx can be rendered as pages
Supports import and export
Automatic refresh on save
Remote (serialised) markdown, e.g on servers
Built-in frontmatter support

Some useful resources

If you want to make a blog, there are some good existing resources including this comprehensive article by Elijah Agbonze.

Another honorable mention is an mdx extension of Vercel's official blog starter example on github by John Polacek, but it is a bit out of date.

Making the Table of Contents

In summary, I use API requests to the server which parses the serialised front matter, extracting the titles similar to Josh Comeau's solution, but using @next/mdx to render mdx, which allows us to keep some useful features of .mdx files.

The MDXProvider wraps the page in _app.js to create a context that allows the mdx files access to the supplied components. The wrapper component is a special component that will wrap the mdx content.

// _app.js

import './markdown-wrapper.js'

const MDXComponents = {
  // ...
  wrapper: ({components, ...props}) => <MarkdownWrapper components={components}{...props} />,
  // ...
};

function MyApp({ Component, pageProps }) {
  return (
    <>
      <MDXProvider components={MDXComponents}>
          <Component {...pageProps} />
      </MDXProvider>
    </>
  )
}

export default MyApp

In markdown-wrapper.js I create a state for headings, make an API request to retrieve headings, and then add it to the page.

// markdown-wrapper.js

import classes from './markdown-wrapper.module.css'
import React, { useEffect, useState } from 'react';
import {useRouter} from 'next/router';
import TableOfContents from '../../components/posts/table-of-contents';

const MarkdownWrapper = (props) => {
  const [headings, setHeadings] = useState('');

  const router = useRouter()
  
  const loadContents = async () => {
    const slug = router.asPath.replace(/\/(\w+)\//, '')
    const postsFolder = router.xxasPath.match(/\w+/)[0]
    const res = await fetch(`/api/post-toc?slug=${slug}&postsFolder=${postsFolder}`);
    const data = await res.json();
    setHeadings(data)
  };
  
  useEffect(() => {
    loadContents()
  }, [])

  return (
    <>
      <div className={classes.container}>
        {headings && <TableOfContents headings={headings}/> }
        <div id="introduction"></div>
        <div className={classes.postContainer} {...props}></div>
      </div>
    </>
  );
}

To make the url link to ech page sections work, I assign a lower case id to each heading based on the title name and then update the component styles

// _app.js

import './markdown-wrapper.js'
  const Heading2 = ({ children }) => {
    const idText = children.replace(/ /g, "_").toLowerCase();
    return <h2 id={idText} className={classes.heading2}>{children}</h2>;
};

const MDXComponents = {
  // ...
  h2: Heading2,
  wrapper: ({components, ...props}) => <MarkdownWrapper components={components}{...props} />,
  // ...
};

//...

Note that I manually insert the introduction in markdown-wrapper.js which I then hid in the styles module.

Conclusion

To be honest, this whole activity, was a bit of a learning excercise, which was not efficient from the perspective of making a blog site. But I am looking forward to tinkering with other aspects of the page as I realise just how flexible and powerful a Next.js + mdx approach to blogging is.