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
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:
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.
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
.
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.
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:
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.
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:
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.