Create a Table of Contents for a Next.js Markdown Blog

Build an auto-generated table of contents, which can highlight its items based on the current scrolling position

·

22 min read

Introduction

With all the available features offered in Next.js, you can easily create a full-fledged blog with markdown and Latex support. It enables you to write the articles in a markdown file and publish them statically. The blog pages will be generated based on the name of the markdown files, their YAML metadata, and content. Each markdown file is an article that will be rendered as an HTML page. However, to easily navigate through the page, we need a dynamic table of contents that enables us to jump to a specific heading and shows the current position in the article. In this article, we are going to build an auto-generated table of contents based on the headings of a markdown file. The output results will be as follows:

Next.js Markdown Blog with ToC support

To view the full source code, you can visit nextjs-markdown-blog-with-toc or clone the repository:

1$ git clone https://github.com/mirbostani/nextjs-markdown-blog-with-toc 2$ cd nextjs-markdown-blog-with-toc 3$ npm install 4$ npm run dev
1$ git clone https://github.com/mirbostani/nextjs-markdown-blog-with-toc 2$ cd nextjs-markdown-blog-with-toc 3$ npm install 4$ npm run dev

Create a Next.js App

The initial step is to create a Next.js project. First, make a new directory and navigate to it. Use the create-next-app tool to initialize a Next.js project and install dependencies.

1$ mkdir nextjs-markdown-blog-with-toc && cd nextjs-markdown-blog-with-toc 2$ npx create-next-app . --use-npm
1$ mkdir nextjs-markdown-blog-with-toc && cd nextjs-markdown-blog-with-toc 2$ npx create-next-app . --use-npm

If you haven't already had create-next-app on your system, install this CLI tool with npm.

1$ npm i create-next-app
1$ npm i create-next-app

Add "Posts" Page

First, we want to show a list of articles containing titles, descriptions, the author's name, and the publish date. Therefore, we need a top-level page to present this list. A suitable route would be localhost:3000/posts. To add it to the website, create the pages/posts.js file.

Every .js file inside the pages directory maps to a route on your website.

Inside the pages/posts.js file, write a function named Posts and export it as the default component. Posts receives props.posts as the input parameter that is an array of objects. Each object is associated with a single markdown file, i.e., a single blog post. Every post has a slug, which is the last part of the URL of the post, a title, description, author's name, and a publish date.

By iterating over the posts array with the Array.prototype.map() function, we can create a list of blog posts containing the blog title and description along with its metadata. Each blog post is wrapped around a <Link> component. It lets you navigate to different pages inside your website. Viewers of the website should be able to browse any blog post by simply clicking on it in the list. Therefore, href attribute of the <Link> is set to /posts/<slug>. Additionally, due to the uniqueness of the slug we can use it as the value of the key attribute for map's returned components. This key is mandatory for React.js to be able to determine the changes in the components of an array.

1// pages/posts.js 2 3import Link from "next/link"; 4 5export default function Posts({ posts }) { 6 return ( 7 <div className="posts"> 8 {posts.length > 0 && 9 posts.map(({ slug, title, description, author, date }) => { 10 return ( 11 <Link key={slug} href={`/posts/${slug}`}> 12 <div className="card"> 13 <h1>{title}</h1> 14 <p className="desc">{description}</p> 15 <p className="meta"> 16 <span>{`By ${author}`}</span> 17 <span>{date}</span> 18 </p> 19 </div> 20 </Link> 21 ); 22 })} 23 </div> 24 ); 25}
1// pages/posts.js 2 3import Link from "next/link"; 4 5export default function Posts({ posts }) { 6 return ( 7 <div className="posts"> 8 {posts.length > 0 && 9 posts.map(({ slug, title, description, author, date }) => { 10 return ( 11 <Link key={slug} href={`/posts/${slug}`}> 12 <div className="card"> 13 <h1>{title}</h1> 14 <p className="desc">{description}</p> 15 <p className="meta"> 16 <span>{`By ${author}`}</span> 17 <span>{date}</span> 18 </p> 19 </div> 20 </Link> 21 ); 22 })} 23 </div> 24 ); 25}

To populate props.posts of the Posts component with the data associated with the markdown files, we can make use of getStaticProps() method of the posts.js page. On website's static built, it returns the props object and passes it to the default component of the page, i.e., the Posts function. Inside, we call a function named getPosts() to retrieve posts' data.

1// pages/posts.js 2 3import Link from "next/link"; 4import { getPosts } from "../libs/posts"; 5 6export async function getStaticProps() { 7 const posts = getPosts({ page: 1, limit: 10 }); 8 return { 9 props: { 10 posts, 11 }, 12 }; 13} 14 15export default function Posts({ posts }) { ... }
1// pages/posts.js 2 3import Link from "next/link"; 4import { getPosts } from "../libs/posts"; 5 6export async function getStaticProps() { 7 const posts = getPosts({ page: 1, limit: 10 }); 8 return { 9 props: { 10 posts, 11 }, 12 }; 13} 14 15export default function Posts({ posts }) { ... }

Parse Markdown Files

Functions related to the data retrieval are in the lib/posts.js file. We define the getPosts() function inside it. This function reads all the markdown files inside a specified directory (./posts/*.md), parses the files' content to extract the metadata in YAML format, and applies a pagination mechanism.

A simple example of a markdown file with front matter is as follows:

1--- 2title: "How to create a Next.js App" 3description: "In this post you will learn how to create a Next.js application" 4date: "2022-05-24" 5author: "mirbostani" 6--- 7 8## Introduction 9 10Next.js is an open-source web development framework. 11 12...
1--- 2title: "How to create a Next.js App" 3description: "In this post you will learn how to create a Next.js application" 4date: "2022-05-24" 5author: "mirbostani" 6--- 7 8## Introduction 9 10Next.js is an open-source web development framework. 11 12...

To parse YAML front matter, we use gray-matter package.

1$ npm install --save gray-matter
1$ npm install --save gray-matter

As the *.md files are placed inside the posts directory, we resolve the path using path.resolve("posts"). We read the file names synchronously with fs.readdirSync().

The file name without the trailing .md string is the slug. We read the file content by fs.readFileSync() and parse the content with the matter function. We use the matter(content).data object to get the returned values. These properties are stored in the posts array. Each post object contains slug, content in markdown, metadata, and other properties.

Before returning the posts array, we call the Array.prototype.sort() method to sort the data based on the date metadata.

The last line implements pagination based on the page and limit parameters.

1// libs/posts.js 2 3import fs from "node:fs"; 4import path from "node:path"; 5import * as matter from "gray-matter"; 6 7export function getPosts({ page, limit }) { 8 page = page ?? 1; 9 limit = limit ?? 10; 10 11 // Read the markdown files (`posts/*.md`) 12 const dirPath = path.resolve("posts"); 13 const fileNames = fs.readdirSync(dirPath); 14 15 // Prepare posts containing slug, content, and metadata 16 let posts = fileNames.map((fileName) => { 17 const slug = fileName.replace(/\.md$/i, ""); 18 const filePath = path.join(dirPath, fileName); 19 const content = fs.readFileSync(filePath, { encoding: "utf-8" }); 20 const matterObject = matter(content); 21 return { 22 slug, 23 ...matterObject.data, 24 }; 25 }); 26 27 // Sort posts based on `date` 28 posts 29 .sort(({ date: a }, { date: b }) => { 30 if (a > b) return 1; 31 else if (a < b) return -1; 32 else return 0; 33 }) 34 .reverse(); 35 36 // Pagination 37 return posts.slice((page - 1) * limit, page * limit); 38}
1// libs/posts.js 2 3import fs from "node:fs"; 4import path from "node:path"; 5import * as matter from "gray-matter"; 6 7export function getPosts({ page, limit }) { 8 page = page ?? 1; 9 limit = limit ?? 10; 10 11 // Read the markdown files (`posts/*.md`) 12 const dirPath = path.resolve("posts"); 13 const fileNames = fs.readdirSync(dirPath); 14 15 // Prepare posts containing slug, content, and metadata 16 let posts = fileNames.map((fileName) => { 17 const slug = fileName.replace(/\.md$/i, ""); 18 const filePath = path.join(dirPath, fileName); 19 const content = fs.readFileSync(filePath, { encoding: "utf-8" }); 20 const matterObject = matter(content); 21 return { 22 slug, 23 ...matterObject.data, 24 }; 25 }); 26 27 // Sort posts based on `date` 28 posts 29 .sort(({ date: a }, { date: b }) => { 30 if (a > b) return 1; 31 else if (a < b) return -1; 32 else return 0; 33 }) 34 .reverse(); 35 36 // Pagination 37 return posts.slice((page - 1) * limit, page * limit); 38}

Start Dev Server

Run the next dev -p 3000 command to start the development server. It is assigned to the NPM's dev script in package.json file.

1$ npm run dev
1$ npm run dev

Navigate to localhost:3000/posts in your favorite browser.

Apply Styles

Add the following CSS rules to the styles/global.css file:

1/* styles/globals.css */ 2 3:root { 4 --muted-color: gray; 5 --hover-color: #f7f7f7; 6} 7 8.posts { 9 margin: 2rem auto; 10 max-width: 1024px; 11 border: 1px solid var(--muted-color); 12} 13 14.card { 15 padding: 1rem 2rem; 16 cursor: pointer; 17} 18 19.card:hover { 20 background-color: var(--hover-color); 21} 22 23.card .desc { 24 margin-top: 0; 25} 26 27.card .meta { 28 display: flex; 29 flex-direction: row; 30 gap: 16px; 31 color: var(--muted-color); 32 margin-top: 0; 33}
1/* styles/globals.css */ 2 3:root { 4 --muted-color: gray; 5 --hover-color: #f7f7f7; 6} 7 8.posts { 9 margin: 2rem auto; 10 max-width: 1024px; 11 border: 1px solid var(--muted-color); 12} 13 14.card { 15 padding: 1rem 2rem; 16 cursor: pointer; 17} 18 19.card:hover { 20 background-color: var(--hover-color); 21} 22 23.card .desc { 24 margin-top: 0; 25} 26 27.card .meta { 28 display: flex; 29 flex-direction: row; 30 gap: 16px; 31 color: var(--muted-color); 32 margin-top: 0; 33}

The class names are added to the Posts component's children in pages/posts.js using the className attribute. The following output will be displayed on your browser.

Posts list

Add Blog Page

Create the pages/posts/[slug].js file. The Post component receives two parameters: slug and post. They are populated by the getStaticProps() method. When a URL like localhost:3000/posts/<slug> is opened in a browser, the value of <slug> part of the URL will be accessible in getStaticProps() using props.params.slug variable.

1// pages/posts/[slug].js 2 3import { getPost, getPostsSlugs } from "../../libs/posts"; 4 5export async function getStaticPaths() { 6 const paths = getPostsSlugs(); 7 return { 8 paths, 9 fallback: false, 10 }; 11} 12 13export async function getStaticProps({ params }) { 14 const slug = params.slug; 15 const post = await getPost(slug); 16 return { 17 props: { 18 slug, 19 post, 20 }, 21 }; 22} 23 24export default function Post({ slug, post }) { 25 return ( 26 <div className="post"> 27 <h1>{post.title}</h1> 28 <p className="desc">{post.description}</p> 29 <div className="meta"> 30 <span>{`By ${post.author}`}</span> 31 <span>{post.date}</span> 32 </div> 33 <div className="content">{post.contentMarkdown}</div> 34 </div> 35 ); 36}
1// pages/posts/[slug].js 2 3import { getPost, getPostsSlugs } from "../../libs/posts"; 4 5export async function getStaticPaths() { 6 const paths = getPostsSlugs(); 7 return { 8 paths, 9 fallback: false, 10 }; 11} 12 13export async function getStaticProps({ params }) { 14 const slug = params.slug; 15 const post = await getPost(slug); 16 return { 17 props: { 18 slug, 19 post, 20 }, 21 }; 22} 23 24export default function Post({ slug, post }) { 25 return ( 26 <div className="post"> 27 <h1>{post.title}</h1> 28 <p className="desc">{post.description}</p> 29 <div className="meta"> 30 <span>{`By ${post.author}`}</span> 31 <span>{post.date}</span> 32 </div> 33 <div className="content">{post.contentMarkdown}</div> 34 </div> 35 ); 36}

Define getPost() and getPostsSlugs() methods in libs/posts.js. In getPosts(), slug is valid through the passed parameter. We use it to open the markdown file with the same base name, parse its content, and return it along with slug and other metadata.

Here, getPostsSlugs() is responsible for providing all the available slugs. They are used in Next.js to build the static website's internal links. We iterate over all the file names, extract, and return the slugs.

1import fs from "node:fs"; 2import path from "node:path"; 3import * as matter from "gray-matter"; 4 5export function getPosts({ page, limit }) { ... } 6 7export async function getPost(slug) { 8 const dirPath = path.resolve("posts"); 9 const filePath = path.join(dirPath, `${slug}.md`); 10 const content = fs.readFileSync(filePath, { encoding: "utf-8" }); 11 const matterObject = matter(content); 12 return { 13 slug, 14 contentMarkdown: matterObject.content, 15 ...matterObject.data, 16 }; 17} 18 19export function getPostsSlugs() { 20 const dirPath = path.resolve("posts"); 21 const fileNames = fs.readdirSync(dirPath); 22 return fileNames.map((fileName) => { 23 const slug = fileName.replace(/\.md/, ""); 24 return { 25 params: { 26 slug, 27 }, 28 }; 29 }); 30}
1import fs from "node:fs"; 2import path from "node:path"; 3import * as matter from "gray-matter"; 4 5export function getPosts({ page, limit }) { ... } 6 7export async function getPost(slug) { 8 const dirPath = path.resolve("posts"); 9 const filePath = path.join(dirPath, `${slug}.md`); 10 const content = fs.readFileSync(filePath, { encoding: "utf-8" }); 11 const matterObject = matter(content); 12 return { 13 slug, 14 contentMarkdown: matterObject.content, 15 ...matterObject.data, 16 }; 17} 18 19export function getPostsSlugs() { 20 const dirPath = path.resolve("posts"); 21 const fileNames = fs.readdirSync(dirPath); 22 return fileNames.map((fileName) => { 23 const slug = fileName.replace(/\.md/, ""); 24 return { 25 params: { 26 slug, 27 }, 28 }; 29 }); 30}

View Blog Post

To check if the functions are working properly, navigate to the localhost:3000/posts/<slug> link in which <slug> is the base name of a markdown file. For example, if your file is located in posts/how-to-create-a-nextjs-app.md, open the localhost:3000/posts/how-to-create-a-nextjs-app URL.

In files' path, posts refers to a local directory; however, in URLs, posts refers to the directory located in pages/posts.

Raw blog post

Now apply some styles by modifying styles/global.css and adding the following classes.

1.post { 2 width: 1024px; 3 margin: 0 auto; 4} 5 6.desc { 7 margin-top: 0; 8} 9 10.meta { 11 display: flex; 12 flex-direction: row; 13 gap: 16px; 14 color: var(--muted-color); 15 margin-top: 0; 16} 17 18.content { 19 margin: 2rem 0; 20}
1.post { 2 width: 1024px; 3 margin: 0 auto; 4} 5 6.desc { 7 margin-top: 0; 8} 9 10.meta { 11 display: flex; 12 flex-direction: row; 13 gap: 16px; 14 color: var(--muted-color); 15 margin-top: 0; 16} 17 18.content { 19 margin: 2rem 0; 20}

If your dev server is running, you will see the changes instantaneously.

Styled blog post

Render Markdown

As you can see, the markdown content is displayed as plain text. We need some tools to convert it to a proper HTML format. Install the following packages in your project:

1$ npm install --save react-markdown remark-math rehype-katex react-syntax-highlighter
1$ npm install --save react-markdown remark-math rehype-katex react-syntax-highlighter

Create a Markdown component in the components/markdown.js file. This component is a wrapper for ReactMarkdown component, which is responsible for converting markdown to HTML.

To support rendering mathematic formulas on the website, we use the remark-math and rehype-katex packages.

ReactMarkdown has a components attribute. You can pass a function to access HTML tags and add features. To highlight the code, first, we access the <code> tag inside the components() function. We filter the ones with the language-* class, for example, language-js. Then, we wrap it with the SyntaxHighlighter component, passing Prism style, setting the language, enabling line numbers, passing the children, etc. However, when there is no match in the language-* CSS class search, we return a plain <code> tag accordingly.

1// components/markdown.js 2 3import ReactMarkdown from "react-markdown"; 4 5// Math support 6import remarkMath from "remark-math"; 7import rehypeKatex from "rehype-katex"; 8import "katex/dist/katex.min.css"; 9 10// Syntax highlighting support 11import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 12import { prism as LightStyle } from "react-syntax-highlighter/dist/cjs/styles/prism"; 13 14function components() { 15 return { 16 code({ node, inline, className, children, ...props }) { 17 const match = /language-(\w+)/.exec(className || ""); 18 return !inline && match ? ( 19 // Highlighted code 20 <> 21 <SyntaxHighlighter 22 style={LightStyle} 23 language={match[1]} 24 PreTag="div" 25 // eslint-disable-next-line react/no-children-prop 26 children={String(children).replace(/\n$/, "")} 27 showLineNumbers={true} 28 customStyle={{ 29 backgroundColor: "rgba(245, 245, 245, 1)", 30 opacity: "1", 31 }} 32 codeTagProps={{ 33 style: { 34 backgroundColor: "transparent", 35 }, 36 }} 37 {...props} 38 /> 39 </> 40 ) : ( 41 // Plain code 42 <code {...props}> 43 {children} 44 </code> 45 ); 46 }, 47 }; 48} 49 50// Wrapper for ReactMarkdown 51export default function Markdown({ children }) { 52 return ( 53 <ReactMarkdown 54 remarkPlugins={[remarkMath]} 55 rehypePlugins={[rehypeKatex]} 56 components={components()} 57 > 58 {children} 59 </ReactMarkdown> 60 ); 61}
1// components/markdown.js 2 3import ReactMarkdown from "react-markdown"; 4 5// Math support 6import remarkMath from "remark-math"; 7import rehypeKatex from "rehype-katex"; 8import "katex/dist/katex.min.css"; 9 10// Syntax highlighting support 11import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 12import { prism as LightStyle } from "react-syntax-highlighter/dist/cjs/styles/prism"; 13 14function components() { 15 return { 16 code({ node, inline, className, children, ...props }) { 17 const match = /language-(\w+)/.exec(className || ""); 18 return !inline && match ? ( 19 // Highlighted code 20 <> 21 <SyntaxHighlighter 22 style={LightStyle} 23 language={match[1]} 24 PreTag="div" 25 // eslint-disable-next-line react/no-children-prop 26 children={String(children).replace(/\n$/, "")} 27 showLineNumbers={true} 28 customStyle={{ 29 backgroundColor: "rgba(245, 245, 245, 1)", 30 opacity: "1", 31 }} 32 codeTagProps={{ 33 style: { 34 backgroundColor: "transparent", 35 }, 36 }} 37 {...props} 38 /> 39 </> 40 ) : ( 41 // Plain code 42 <code {...props}> 43 {children} 44 </code> 45 ); 46 }, 47 }; 48} 49 50// Wrapper for ReactMarkdown 51export default function Markdown({ children }) { 52 return ( 53 <ReactMarkdown 54 remarkPlugins={[remarkMath]} 55 rehypePlugins={[rehypeKatex]} 56 components={components()} 57 > 58 {children} 59 </ReactMarkdown> 60 ); 61}

Now use Markdown by updating the Post component in pages/posts/[slug].js. Pass post.contentMarkdown to the Markdown component as a child.

1export default function Post({ slug, post }) { 2 return ( 3 <div className="post"> 4 <h1>{post.title}</h1> 5 <p className="desc">{post.description}</p> 6 <div className="meta"> 7 <span>{`By ${post.author}`}</span> 8 <span>{post.date}</span> 9 </div> 10 <article className="content"> 11 <Markdown>{post.contentMarkdown}</Markdown> 12 </article> 13 </div> 14 ); 15}
1export default function Post({ slug, post }) { 2 return ( 3 <div className="post"> 4 <h1>{post.title}</h1> 5 <p className="desc">{post.description}</p> 6 <div className="meta"> 7 <span>{`By ${post.author}`}</span> 8 <span>{post.date}</span> 9 </div> 10 <article className="content"> 11 <Markdown>{post.contentMarkdown}</Markdown> 12 </article> 13 </div> 14 ); 15}

Here is an example:

Code and Math in Markdown

Create ToC Widget

In this step, we want to create an auto-generated table of contents based on the headings available in the markdown file of the article. The following packages are required:

1$ npm install --save remark remark-html jsdom
1$ npm install --save remark remark-html jsdom

Modify the libs/posts.js file by adding the getHeadingAnchors() function to it. In getPost() method, we use remark() to parse the markdown content to HTML. Then, we pass it to the getHeadingAnchors() function, which is responsible for finding all the <h1>, <h2>, and <h3> tags, creating an ID based on the text content of the tag, and return the heading's type (heading), title (title), and prepared ID (anchorId). Finally, getPost() returns the heading anchors along with other properties.

1import fs from "node:fs"; 2import path from "node:path"; 3import * as matter from "gray-matter"; 4 5import { remark } from "remark"; 6import remarkHtml from "remark-html"; 7import jsdom from "jsdom"; 8 9export function getPosts({ page, limit }) { ... } 10 11export async function getPost(slug) { 12 const dirPath = path.resolve("posts"); 13 const filePath = path.join(dirPath, `${slug}.md`); 14 const content = fs.readFileSync(filePath, { encoding: "utf-8" }); 15 const matterObject = matter(content); 16 17 // Get heading anchors 18 const processedContent = await remark() 19 .use(remarkHtml) 20 .process(matterObject.content); 21 const contentHtml = processedContent.toString(); 22 const { headingAnchors, contentHtmlWithAnchors } = 23 getHeadingAnchors(contentHtml); 24 25 return { 26 slug, 27 headingAnchors, // return the anchors 28 contentMarkdown: matterObject.content, 29 ...matterObject.data, 30 }; 31} 32 33// Find and return anchors based on headings 34function getHeadingAnchors(str) { 35 const headingAnchors = []; 36 const dom = new jsdom.JSDOM(`<div id="headinganchors">${str}</div>`); 37 dom.window.document.querySelectorAll("h1, h2, h3").forEach((hx) => { 38 const id = hx.textContent.toLowerCase().replace(/\s+|[^a-z0-9]/g, "_"); 39 const anchor = dom.window.document.createElement("a"); 40 anchor.id = id; 41 hx.insertBefore(anchor, null); 42 43 headingAnchors.push({ 44 heading: hx.nodeName.toLowerCase(), 45 title: hx.textContent, 46 anchorId: id, 47 }); 48 }); 49 const contentHtmlWithAnchors = 50 dom.window.document.getElementById("headinganchors").innerHTML; 51 return { 52 headingAnchors, 53 contentHtmlWithAnchors, 54 }; 55}
1import fs from "node:fs"; 2import path from "node:path"; 3import * as matter from "gray-matter"; 4 5import { remark } from "remark"; 6import remarkHtml from "remark-html"; 7import jsdom from "jsdom"; 8 9export function getPosts({ page, limit }) { ... } 10 11export async function getPost(slug) { 12 const dirPath = path.resolve("posts"); 13 const filePath = path.join(dirPath, `${slug}.md`); 14 const content = fs.readFileSync(filePath, { encoding: "utf-8" }); 15 const matterObject = matter(content); 16 17 // Get heading anchors 18 const processedContent = await remark() 19 .use(remarkHtml) 20 .process(matterObject.content); 21 const contentHtml = processedContent.toString(); 22 const { headingAnchors, contentHtmlWithAnchors } = 23 getHeadingAnchors(contentHtml); 24 25 return { 26 slug, 27 headingAnchors, // return the anchors 28 contentMarkdown: matterObject.content, 29 ...matterObject.data, 30 }; 31} 32 33// Find and return anchors based on headings 34function getHeadingAnchors(str) { 35 const headingAnchors = []; 36 const dom = new jsdom.JSDOM(`<div id="headinganchors">${str}</div>`); 37 dom.window.document.querySelectorAll("h1, h2, h3").forEach((hx) => { 38 const id = hx.textContent.toLowerCase().replace(/\s+|[^a-z0-9]/g, "_"); 39 const anchor = dom.window.document.createElement("a"); 40 anchor.id = id; 41 hx.insertBefore(anchor, null); 42 43 headingAnchors.push({ 44 heading: hx.nodeName.toLowerCase(), 45 title: hx.textContent, 46 anchorId: id, 47 }); 48 }); 49 const contentHtmlWithAnchors = 50 dom.window.document.getElementById("headinganchors").innerHTML; 51 return { 52 headingAnchors, 53 contentHtmlWithAnchors, 54 }; 55}

For example, applying getHeadingAnchors() on the following markdown content:

1# Heading 1 2 3## Heading 2
1# Heading 1 2 3## Heading 2

will result in:

1[ 2 { 3 heading: "h1", 4 title: "Heading 1", 5 anchorId: "heading-1" 6 }, 7 { 8 heading: "h2", 9 title: "Heading 2", 10 anchorId: "heading-2" 11 }, 12]
1[ 2 { 3 heading: "h1", 4 title: "Heading 1", 5 anchorId: "heading-1" 6 }, 7 { 8 heading: "h2", 9 title: "Heading 2", 10 anchorId: "heading-2" 11 }, 12]

Now, on the output HTML content, we need to add anchors before headings so that when we click on a link in the table of content, the browser will jump to the respective anchor in the article.

To do so, update the components function in components/markdown.js to add anchors before headings:

1function components() { 2 const hx = (children) => { 3 const id = children 4 .join("") 5 .toLowerCase() 6 .replace(/\s+|[^a-z0-9]/gi, "_"); 7 return ( 8 <> 9 <a id={id} className="heading-anchor"></a> 10 <h3>{children}</h3> 11 </> 12 ); 13 }; 14 return { 15 code({ node, inline, className, children, ...props }) { 16 ... 17 }, 18 h1({ node, inline, className, children, ...props }) { 19 return hx(children); 20 }, 21 h2({ node, inline, className, children, ...props }) { 22 return hx(children); 23 }, 24 h3({ node, inline, className, children, ...props }) { 25 return hx(children); 26 }, 27 }; 28}
1function components() { 2 const hx = (children) => { 3 const id = children 4 .join("") 5 .toLowerCase() 6 .replace(/\s+|[^a-z0-9]/gi, "_"); 7 return ( 8 <> 9 <a id={id} className="heading-anchor"></a> 10 <h3>{children}</h3> 11 </> 12 ); 13 }; 14 return { 15 code({ node, inline, className, children, ...props }) { 16 ... 17 }, 18 h1({ node, inline, className, children, ...props }) { 19 return hx(children); 20 }, 21 h2({ node, inline, className, children, ...props }) { 22 return hx(children); 23 }, 24 h3({ node, inline, className, children, ...props }) { 25 return hx(children); 26 }, 27 }; 28}

If you inspect the output in your browser, you should see <a> tags with id and class set to heading-anchors before headings.

Anchor Before Heading

Modify pages/posts/[slug].js to add a sidebar to the blog page. The sidebar and content are the children of the wrapper <div>.

1// pages/posts/[slug].js 2 3export default function Post({ slug, post }) { 4 return ( 5 <div className="post"> 6 <h1>{post.title}</h1> 7 <p className="desc">{post.description}</p> 8 <div className="meta"> 9 <span>{`By ${post.author}`}</span> 10 <span>{post.date}</span> 11 </div> 12 <div className="wrapper"> 13 <aside className="sidebar"> 14 <div className="toc"> 15 <h1>Table of Contents</h1> 16 {post.headingAnchors.length > 0 && ( 17 <ul> 18 {post.headingAnchors.map((anchor) => { 19 return ( 20 <li 21 id={`toc-${anchor.anchorId}`} 22 key={anchor.anchorId} 23 className={`ml${anchor.heading[1]} toc-anchor`} 24 > 25 <a href={`#${anchor.anchorId}`}>{anchor.title}</a> 26 </li> 27 ); 28 })} 29 </ul> 30 )} 31 </div> 32 </aside> 33 <article className="content"> 34 <Markdown>{post.contentMarkdown}</Markdown> 35 </article> 36 </div> 37 </div> 38 ); 39}
1// pages/posts/[slug].js 2 3export default function Post({ slug, post }) { 4 return ( 5 <div className="post"> 6 <h1>{post.title}</h1> 7 <p className="desc">{post.description}</p> 8 <div className="meta"> 9 <span>{`By ${post.author}`}</span> 10 <span>{post.date}</span> 11 </div> 12 <div className="wrapper"> 13 <aside className="sidebar"> 14 <div className="toc"> 15 <h1>Table of Contents</h1> 16 {post.headingAnchors.length > 0 && ( 17 <ul> 18 {post.headingAnchors.map((anchor) => { 19 return ( 20 <li 21 id={`toc-${anchor.anchorId}`} 22 key={anchor.anchorId} 23 className={`ml${anchor.heading[1]} toc-anchor`} 24 > 25 <a href={`#${anchor.anchorId}`}>{anchor.title}</a> 26 </li> 27 ); 28 })} 29 </ul> 30 )} 31 </div> 32 </aside> 33 <article className="content"> 34 <Markdown>{post.contentMarkdown}</Markdown> 35 </article> 36 </div> 37 </div> 38 ); 39}

We apply some styles to align the sidebar to the right and make it sticky when we scroll down the page. If the window's width is less than 1024px, the sidebar will be placed on top of the article. However, for windows width bigger than 1024px, the sidebar will be on the right side of the page, sticking to the top while scrolling.

1/* styles/global.css */ 2 3.ml1 { 4 margin-left: 1rem; 5} 6 7.ml2 { 8 margin-left: 2rem; 9} 10 11.ml3 { 12 margin-left: 3rem; 13} 14 15.post { 16 width: 1024px; 17 margin: 0 auto; 18} 19 20.post .desc { 21 margin-top: 0; 22} 23 24.post .meta { 25 display: flex; 26 flex-direction: row; 27 color: var(--muted-color); 28 margin-top: 0; 29} 30 31.post .wrapper { 32 display: flex; 33 flex-direction: column; 34} 35 36.post .content { 37 margin: 2rem 0; 38} 39 40.post .sidebar { 41 display: flex; 42 flex-direction: column; 43 margin-left: var(--margin); 44} 45 46.post .toc ul { 47 padding: 0; 48} 49 50.post .toc li { 51 list-style-type: none; 52 font-weight: normal; 53} 54 55.post .toc li.active { 56 font-weight: 800 !important; 57} 58 59@media (min-width: 1024px) { 60 .post .wrapper { 61 flex-direction: row-reverse; 62 } 63 64 .post .sidebar { 65 align-self: flex-start; 66 width: 300px; 67 position: -webkit-sticky; 68 position: sticky; 69 top: 1rem; 70 } 71 72 .post .content { 73 width: calc(100% - var(--margin) - 300px); 74 } 75}
1/* styles/global.css */ 2 3.ml1 { 4 margin-left: 1rem; 5} 6 7.ml2 { 8 margin-left: 2rem; 9} 10 11.ml3 { 12 margin-left: 3rem; 13} 14 15.post { 16 width: 1024px; 17 margin: 0 auto; 18} 19 20.post .desc { 21 margin-top: 0; 22} 23 24.post .meta { 25 display: flex; 26 flex-direction: row; 27 color: var(--muted-color); 28 margin-top: 0; 29} 30 31.post .wrapper { 32 display: flex; 33 flex-direction: column; 34} 35 36.post .content { 37 margin: 2rem 0; 38} 39 40.post .sidebar { 41 display: flex; 42 flex-direction: column; 43 margin-left: var(--margin); 44} 45 46.post .toc ul { 47 padding: 0; 48} 49 50.post .toc li { 51 list-style-type: none; 52 font-weight: normal; 53} 54 55.post .toc li.active { 56 font-weight: 800 !important; 57} 58 59@media (min-width: 1024px) { 60 .post .wrapper { 61 flex-direction: row-reverse; 62 } 63 64 .post .sidebar { 65 align-self: flex-start; 66 width: 300px; 67 position: -webkit-sticky; 68 position: sticky; 69 top: 1rem; 70 } 71 72 .post .content { 73 width: calc(100% - var(--margin) - 300px); 74 } 75}

Active ToC Items

To make the items in the table of contents re-act and become emphasized during the scroll event, first, we have to capture the scroll event. To achieve this effect, we create a state named scrollTop with useState() and set its initial value to 0. Then we capture its changes using the useEffect(() => {}, [scrollTop]) hook inside the Post component.

To capture the scroll event, we call addEventListener on the window object. Don't forget to call removeEventListener when useEffect() is finished.

We call updateTocAnchors() on each scroll update. This method searches for the heading anchors visible on the browser's window and highlights the corresponding item on the table of contents widget based on the heading anchor ID.

1// pages/posts/[slug].js 2 3import { getPost, getPostsSlugs } from "../../libs/posts"; 4import Markdown from "../../components/markdown"; 5 6import { useState, useEffect } from "react"; // React hooks 7 8export async function getStaticPaths() { ... } 9 10export async function getStaticProps({ params }) { ... } 11 12export default function Post({ slug, post }) { 13 const [scrollTop, setScrollTop] = useState(0); 14 15 useEffect(() => { 16 const updateTocAnchors = (e) => { 17 const headingAnchors = e.target.documentElement.querySelectorAll( 18 ".post .article .heading-anchor" 19 ); 20 if (headingAnchors.length === 0) return; 21 const activeHeadingAnchors = Array.from(headingAnchors).filter( 22 (anchor) => { 23 const rect = anchor.getBoundingClientRect(); 24 return ( 25 rect.top >= 0 && 26 rect.left >= 0 && 27 rect.bottom <= e.target.documentElement.clientHeight && 28 rect.right <= e.target.documentElement.clientWidth 29 ); 30 } 31 ); 32 if (activeHeadingAnchors.length === 0) return; 33 // Deactive all ToC anchors 34 const tocAnchors = e.target.documentElement.querySelectorAll( 35 ".post .sidebar .toc .toc-anchor" 36 ); 37 if (tocAnchors.length === 0) return; 38 Array.from(tocAnchors).forEach((anchor) => { 39 anchor.classList.remove("active"); 40 }); 41 // Active current ToC anchor 42 const tocAnchorId = `toc-${activeHeadingAnchors[0].id}`; 43 const activeTocAnchor = e.target.documentElement.querySelector( 44 `#${tocAnchorId}` 45 ); 46 if (!activeTocAnchor) return; 47 activeTocAnchor.classList.add("active"); 48 }; 49 50 // Capturing the `scroll` event 51 const onScroll = (e) => { 52 const currScrollTop = e.target.documentElement.scrollTop; 53 setScrollTop((prevScrollTop, props) => { 54 updateTocAnchors(e); 55 if (Math.abs(prevScrollTop - currScrollTop) > 200) { 56 return currScrollTop; 57 } 58 }); 59 }; 60 window.addEventListener("scroll", onScroll); 61 return () => window.removeEventListener("scroll", onScroll); 62 }, [scrollTop]); 63 64 return ( ... ); 65}
1// pages/posts/[slug].js 2 3import { getPost, getPostsSlugs } from "../../libs/posts"; 4import Markdown from "../../components/markdown"; 5 6import { useState, useEffect } from "react"; // React hooks 7 8export async function getStaticPaths() { ... } 9 10export async function getStaticProps({ params }) { ... } 11 12export default function Post({ slug, post }) { 13 const [scrollTop, setScrollTop] = useState(0); 14 15 useEffect(() => { 16 const updateTocAnchors = (e) => { 17 const headingAnchors = e.target.documentElement.querySelectorAll( 18 ".post .article .heading-anchor" 19 ); 20 if (headingAnchors.length === 0) return; 21 const activeHeadingAnchors = Array.from(headingAnchors).filter( 22 (anchor) => { 23 const rect = anchor.getBoundingClientRect(); 24 return ( 25 rect.top >= 0 && 26 rect.left >= 0 && 27 rect.bottom <= e.target.documentElement.clientHeight && 28 rect.right <= e.target.documentElement.clientWidth 29 ); 30 } 31 ); 32 if (activeHeadingAnchors.length === 0) return; 33 // Deactive all ToC anchors 34 const tocAnchors = e.target.documentElement.querySelectorAll( 35 ".post .sidebar .toc .toc-anchor" 36 ); 37 if (tocAnchors.length === 0) return; 38 Array.from(tocAnchors).forEach((anchor) => { 39 anchor.classList.remove("active"); 40 }); 41 // Active current ToC anchor 42 const tocAnchorId = `toc-${activeHeadingAnchors[0].id}`; 43 const activeTocAnchor = e.target.documentElement.querySelector( 44 `#${tocAnchorId}` 45 ); 46 if (!activeTocAnchor) return; 47 activeTocAnchor.classList.add("active"); 48 }; 49 50 // Capturing the `scroll` event 51 const onScroll = (e) => { 52 const currScrollTop = e.target.documentElement.scrollTop; 53 setScrollTop((prevScrollTop, props) => { 54 updateTocAnchors(e); 55 if (Math.abs(prevScrollTop - currScrollTop) > 200) { 56 return currScrollTop; 57 } 58 }); 59 }; 60 window.addEventListener("scroll", onScroll); 61 return () => window.removeEventListener("scroll", onScroll); 62 }, [scrollTop]); 63 64 return ( ... ); 65}

After combining all the codes and logic, the results will be as follows:

Article with ToC

Conclusion

We are done! We have created an auto-generated table of contents, which can highlight its items based on the current scrolling position.

You can find the source code on the nextjs-markdown-blog-with-toc repository.