喂!我是 Wei

Front-End Engineer

Be a Problem Solver.

⌘K

導覽

所有文章緣起互動小功能

文章分類

目錄
整體資料流第一步:定義資料型別第二步:從 MDX 提取標題第三步:在 getPostBySlug 回傳 toc第四步:建立 <Toc /> 元件IntersectionObserver 的 rootMargin 調整第五步:@right Parallel Routes 與 TOC 的放置位置小結

相關文章

在 Next.js 使用 MDX 撰寫技術文章

2026年3月6日

Next.js Blog 加入程式碼高亮(Shiki + rehype-pretty-code)

2026年3月6日

前端工程師面試題:CSR、SSR、SSG 差在哪?Next.js 為什麼適合 SEO?

2026年6月2日

最新文章
全部 →
前端 CI/CD 與正式環境除錯:從 Pull Request 到事故排查
2026-06-24
即時資料怎麼選?Polling、SSE、WebSocket 比較
2026-06-23
前端系統設計:如何拆元件、資料流與大型專案架構?
2026-06-22
無障礙不是加 ARIA:語意化 HTML、鍵盤操作與焦點管理
2026-06-21
CSS 與 RWD 面試整理:Flexbox、Grid、定位與層疊脈絡
2026-06-19
← 返回文章列表

Next.js Blog 實作文章目錄(TOC)

2026年3月7日·約 6 分鐘閱讀·
Next.jsMDXTOC

閱讀一篇較長的技術文章時,旁邊有一個**目錄(Table of Contents)**可以快速跳轉章節,是很常見的設計。

這篇會介紹這個 Blog 的 TOC 是怎麼做的,包含:

  • 如何從 MDX 文章內容自動提取標題
  • 如何把標題渲染成可點擊的目錄
  • 如何用 IntersectionObserver 追蹤當前閱讀位置,高亮對應的目錄項目

整體資料流

TOC 的資料流大致如下:

MDX 文章原始內容(.mdx 檔案)
        │
        ▼
extractToc()      ← 用 regex 解析 ## / ### 標題
        │
        ▼
TocItem[]         ← [{ id, text, level }]
        │
        ▼
<Toc /> 元件      ← 渲染成目錄清單,用 IntersectionObserver 追蹤

解析和渲染是分開的:解析在 Server 端做,追蹤在 Client 端做。


第一步:定義資料型別

在 lib/posts.ts 定義 TocItem,描述每一個目錄項目:

lib/posts.ts
export type TocItem = {
  id: string;   // heading 的 id,對應 rehypeSlug 產生的值
  text: string; // 標題文字
  level: 2 | 3; // h2 或 h3
};

只處理 h2 和 h3,層次夠用,也不會讓目錄過深。


第二步:從 MDX 提取標題

在 Server 端解析 MDX 原始文字,用 regex 找出所有 ## 和 ### 標題:

lib/posts.ts
import GithubSlugger from "github-slugger";
 
function extractToc(mdx: string): TocItem[] {
  const slugger = new GithubSlugger();
  const toc: TocItem[] = [];
  const lines = mdx.split("\n");
  let inFence = false;
 
  for (const line of lines) {
    // 跳過 code fence 內的標題(避免把程式碼裡的 # 當成標題)
    if (line.trim().startsWith("```")) {
      inFence = !inFence;
      continue;
    }
    if (inFence) continue;
 
    const m = /^(#{2,3})\s+(.+?)\s*$/.exec(line);
    if (!m) continue;
 
    const level = m[1].length as 2 | 3;
    const text = m[2]
      .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // 去掉 markdown link
      .replace(/`([^`]+)`/g, "$1")              // 去掉 inline code
      .trim();
 
    const id = slugger.slug(text);
    toc.push({ id, text, level });
  }
 
  return toc;
}

這裡有一個細節很重要:產生 id 要用 github-slugger,跟 rehype-slug 用的演算法一樣。如果兩邊不一樣,目錄點下去就會跳不到正確的位置。


第三步:在 getPostBySlug 回傳 toc

lib/posts.ts
export const getPostBySlug = cache(async (slug: string) => {
  const raw = fs.readFileSync(filePath, "utf8");
  const { data, content } = matter(raw);
 
  const toc = extractToc(content); // ← 在 Server 端解析
 
  const mdx = await compileMDX({ /* ... */ });
 
  return { meta, toc, content: mdx.content }; // ← toc 一起回傳
});

第四步:建立 <Toc /> 元件

<Toc /> 是一個 Client Component,負責渲染目錄並用 IntersectionObserver 追蹤當前閱讀位置:

components/Toc.tsx
"use client";
 
import { useEffect, useRef, useState } from "react";
import type { TocItem } from "@/lib/posts";
 
export default function Toc({ toc }: { toc: TocItem[] }) {
  const [activeId, setActiveId] = useState<string>("");
  const observerRef = useRef<IntersectionObserver | null>(null);
 
  useEffect(() => {
    if (!toc.length) return;
 
    // 找到所有標題元素
    const headingElements = toc
      .map(({ id }) => document.getElementById(id))
      .filter(Boolean) as HTMLElement[];
 
    observerRef.current = new IntersectionObserver(
      (entries) => {
        // 找出在畫面中最靠近頂部的標題
        const visible = entries
          .filter((e) => e.isIntersecting)
          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
 
        if (visible.length > 0) {
          setActiveId(visible[0].target.id);
        }
      },
      {
        rootMargin: "-10% 0px -70% 0px", // 只偵測畫面上方 20% 的區域
        threshold: 0,
      }
    );
 
    headingElements.forEach((el) => observerRef.current!.observe(el));
 
    return () => { observerRef.current?.disconnect(); };
  }, [toc]);
 
  if (!toc.length) return null;
 
  return (
    <nav>
      {toc.map((item) => (
        <a
          key={item.id}
          href={`#${item.id}`}
          style={{ marginLeft: item.level === 3 ? "1rem" : "0" }}
          className={item.id === activeId ? "text-cyan-500 font-medium" : "text-gray-500"}
        >
          {item.text}
        </a>
      ))}
    </nav>
  );
}

IntersectionObserver 的 rootMargin 調整

rootMargin: "-10% 0px -70% 0px" 這個設定的意思是:

  • 只把畫面上方 10%~下方 30% 之間的區域算成「可見」
  • 這樣當一個標題進入視野上方時,目錄才會切換,而不是標題一出現在畫面底部就觸發

調整這個值可以改變目錄切換的時機,讓它跟閱讀節奏更吻合。


第五步:@right Parallel Routes 與 TOC 的放置位置

Next.js App Router 有一個功能叫做 Parallel Routes(平行路由),可以讓同一個 layout 根據當前路由,在不同的「插槽(slot)」裡顯示不同的內容。

這個 Blog 的右欄就是利用這個功能實作的:

app/dashboard/
├── layout.tsx            ← 宣告接收 @right slot
├── page.tsx              ← 文章列表
├── @right/
│   ├── default.tsx       ← fallback(列表頁、其他頁面用)
│   ├── about/
│   │   └── page.tsx      ← 進入 /dashboard/about 時右欄顯示這個
│   └── [...catchAll]/
│       └── page.tsx      ← 進入 /dashboard/posts/[slug] 時右欄顯示這個
└── posts/
    └── [slug]/
        └── page.tsx

layout.tsx 宣告接收 right 這個 slot:

app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  right,
}: {
  children: React.ReactNode;
  right: React.ReactNode;
}) {
  return (
    <div className="flex">
      <main>{children}</main>
      <aside>{right}</aside>
    </div>
  );
}

Next.js 會自動根據當前 URL 匹配對應的 @right/*/page.tsx,把它注入到 right 這個 prop。不需要在任何地方寫 if (pathname === ...) 的判斷。

文章頁面對應的右欄(@right/[...catchAll]/page.tsx)就負責渲染 TOC:

app/dashboard/@right/[...catchAll]/page.tsx
import Toc from "@/components/Toc";
import { getPostBySlug } from "@/lib/posts";
 
export default async function RightSlot({
  params,
}: {
  params: { catchAll: string[] };
}) {
  const slug = params.catchAll.at(-1) ?? "";
  const post = await getPostBySlug(slug).catch(() => null);
 
  if (!post?.toc.length) return null;
 
  return <Toc toc={post.toc} />;
}

這樣的設計讓「右欄要顯示什麼」完全由路由決定,每個頁面的右欄邏輯各自獨立,不會互相干擾。


小結

TOC 的實作總共分兩個部分:

部分位置說明
提取標題lib/posts.ts(Server)解析 MDX 原始文字,用 github-slugger 產生 id
渲染目錄components/Toc.tsx(Client)渲染清單,用 IntersectionObserver 追蹤位置

關鍵是兩邊的 id 要一致:extractToc() 和 rehype-slug 都透過 github-slugger 產生 id,才能確保目錄點擊後能正確跳到對應標題。

下一篇會繼續介紹這個 Blog 的全文搜尋功能是怎麼實作的。

分享:XLinkedIn
← 上一篇在 Next.js 使用 MDX 撰寫技術文章
下一篇 →Next.js Blog 不靠後端的全文搜尋