Get your API key
How to add a table of contents to a PDF

How to add a table of contents to a PDF

Add a clickable table of contents and PDF bookmarks: build it from HTML anchors when converting with Chromium, or add outline entries to an existing PDF with pdf-lib.

11 min read

A PDF table of contents has two independent layers, and most developers conflate them. The visible contents page (a list of clickable section titles) comes from HTML anchor links and is created automatically when you convert HTML to PDF with Chromium. The sidebar navigation tree (PDF bookmarks, also called the outline) is a separate PDF feature that Chromium does not generate, so you add it afterward with a library like pdf-lib. If you only need clickable in-document jumps, the HTML approach with PDF4.dev or any Chromium-based renderer is the fastest path and needs zero post-processing.

This guide covers both layers: building the visible TOC from HTML anchors, styling page numbers with CSS, rendering with PDF4.dev, and writing outline bookmarks into an existing PDF.

Table of contents vs PDF bookmarks: which one do you need?

A table of contents is on-page content; PDF bookmarks are reader chrome. The table below maps each navigation type to how it is created and which tool produces it.

FeatureWhat it isWhere it appearsHow it is createdGenerated by Chromium?
Clickable TOCList of links to sectionsA content page in the PDFHTML anchor links (href="#id")Yes, automatically
Page numbers in TOC"Section 3 ..... 12"Next to each TOC entryCSS target-counterPartial (engine dependent)
PDF bookmarks (outline)Sidebar navigation treeReader's outline panelPDF outline objectsNo, add afterward
Cross-references"see page 8" inlineAnywhere in body textCSS target-counterPartial (engine dependent)

The short decision: if your readers open the PDF and click links inside a contents page, you need the HTML TOC and nothing else. If they navigate via the reader's sidebar (common for long reports and ebooks), you also need outline bookmarks added as a post-processing step.

"Outline" and "bookmarks" are the same thing in the PDF specification. The ISO 32000 standard calls it the document outline; Acrobat and most readers label it Bookmarks in the UI.

How do you build a clickable table of contents in HTML?

Give every section a unique id, then create a list of links whose href points to those ids with hash syntax. When a Chromium-based renderer converts the HTML to PDF, each internal anchor link becomes a clickable in-document jump. This is the entire mechanism, no plugin or PDF library involved.

The pattern is two halves that must match exactly: the link target and the element id.

<nav class="toc">
  <h2>Contents</h2>
  <ol>
    <li><a href="#intro">Introduction</a></li>
    <li><a href="#setup">Setup</a></li>
    <li><a href="#api">API reference</a></li>
    <li><a href="#billing">Billing</a></li>
  </ol>
</nav>
 
<section id="intro">
  <h2>Introduction</h2>
  <p>...</p>
</section>
 
<section id="setup">
  <h2>Setup</h2>
  <p>...</p>
</section>
 
<section id="api">
  <h2>API reference</h2>
  <p>...</p>
</section>
 
<section id="billing">
  <h2>Billing</h2>
  <p>...</p>
</section>

The two halves that must match are href="#api" and id="api". A single typo, or an id that does not survive template rendering, leaves the link inert. If you generate sections from data, generate the ids and the links from the same source so they cannot drift.

Use a slug helper to derive ids from titles deterministically. If "API reference" becomes id="api-reference", the TOC link must be href="#api-reference". Generating both from one slug function removes the entire class of typo bugs.

How do you add page numbers to a table of contents?

Use the CSS target-counter() function inside a print stylesheet. It reads the page number where a target anchor lands at print time and prints it in the TOC, so you do not hardcode page numbers that go stale the moment content shifts. Support is engine dependent, and this is the one place where the rendering engine choice matters.

/* Print the target page number after each TOC link */
.toc a::after {
  content: target-counter(attr(href), page);
}
 
/* Optional: a dotted leader between title and page number */
.toc a::after {
  content: leader('.') target-counter(attr(href), page);
}

The catch: target-counter is part of the CSS Generated Content for Paged Media module, which print-focused engines (Prince, WeasyPrint) implement, but Chromium's support is limited. If you render with headless Chromium (the engine behind Playwright, Puppeteer, and PDF4.dev), do not rely on target-counter for automatic page numbers.

Three practical options when your engine lacks target-counter:

  1. Skip page numbers entirely. Clickable links make page numbers redundant for on-screen reading, which is how most PDFs are consumed.
  2. Compute page numbers in a print-CSS engine (WeasyPrint, Prince) if a printed paper TOC with accurate page numbers is a hard requirement.
  3. Add a sidebar outline instead. Reader bookmarks give navigation without any page-number arithmetic.

Do not hardcode page numbers in a TOC for dynamic content. Any change to upstream sections shifts every later page, and a manually typed "page 12" becomes wrong silently. Either use target-counter in a supporting engine or omit the number.

How do you render the HTML to PDF with PDF4.dev?

Send your HTML (with the matching ids and anchor links) to the PDF4.dev render endpoint. It renders with headless Chromium server-side, so internal anchor links become clickable in-document jumps automatically, with no Chromium to install and no serverless cold start to fight. The clickable TOC works out of the box; you only add a sidebar outline if you also need reader bookmarks.

A minimal request with raw HTML:

curl -X POST https://pdf4.dev/api/v1/render \
  -H "Authorization: Bearer p4_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<nav class=\"toc\"><a href=\"#intro\">Introduction</a></nav><section id=\"intro\"><h2>Introduction</h2><p>Hello</p></section>",
    "data": {},
    "delivery": "url"
  }'

In production you usually keep the document in a Handlebars template and pass section data, so the ids and the links are generated from the same array.

const sections = [
  { id: "intro", title: "Introduction", body: "..." },
  { id: "setup", title: "Setup", body: "..." },
  { id: "api", title: "API reference", body: "..." },
];
 
const res = await fetch("https://pdf4.dev/api/v1/render", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PDF4_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    template_id: "report-with-toc",
    data: { sections },
    delivery: "url",
  }),
});
 
const { url } = await res.json();
console.log("PDF ready at", url);

The Handlebars template builds the TOC and the bodies from the same sections array, so each href always matches an existing id:

<nav class="toc">
  <h2>Contents</h2>
  <ol>
    {{#each sections}}
      <li><a href="#{{this.id}}">{{this.title}}</a></li>
    {{/each}}
  </ol>
</nav>
 
{{#each sections}}
  <section id="{{this.id}}">
    <h2>{{this.title}}</h2>
    <p>{{this.body}}</p>
  </section>
{{/each}}

Prefer to try it without writing code first? The free Html To PdfTry it free tool renders pasted HTML with the same Chromium engine, so you can confirm your anchor links jump before wiring up the API.

How do you add PDF bookmarks (an outline) to an existing PDF?

PDF bookmarks are written into the PDF structure after rendering, because Chromium does not produce them. In Node.js you open the finished PDF with pdf-lib, create outline dictionary objects that point at page references, and link them into the document catalog. There is no HTML equivalent; the outline is pure PDF object plumbing.

pdf-lib does not ship a one-call addBookmark() helper, so you write the low-level objects. The shape is: an outline root, one outline item per entry, and a /Dest on each item pointing at a page.

import { PDFDocument, PDFName, PDFArray, PDFNumber } from "pdf-lib";
import { readFile, writeFile } from "node:fs/promises";
 
const bytes = await readFile("report.pdf");
const doc = await PDFDocument.load(bytes);
const pages = doc.getPages();
const context = doc.context;
 
// One bookmark per (title, pageIndex) pair
const entries = [
  { title: "Introduction", pageIndex: 0 },
  { title: "Setup", pageIndex: 2 },
  { title: "API reference", pageIndex: 5 },
];
 
const outlineRef = context.nextRef();
const itemRefs = entries.map(() => context.nextRef());
 
entries.forEach((entry, i) => {
  const page = pages[entry.pageIndex];
  const dest = PDFArray.withContext(context);
  dest.push(page.ref);
  dest.push(PDFName.of("XYZ"));
  dest.push(PDFName.of("null")); // left
  dest.push(PDFName.of("null")); // top
  dest.push(PDFNumber.of(0)); // zoom
 
  const item = context.obj({
    Title: entry.title,
    Parent: outlineRef,
    Dest: dest,
  });
  if (i > 0) item.set(PDFName.of("Prev"), itemRefs[i - 1]);
  if (i < entries.length - 1) item.set(PDFName.of("Next"), itemRefs[i + 1]);
  context.assign(itemRefs[i], item);
});
 
const outline = context.obj({
  Type: "Outlines",
  First: itemRefs[0],
  Last: itemRefs[itemRefs.length - 1],
  Count: entries.length,
});
context.assign(outlineRef, outline);
 
doc.catalog.set(PDFName.of("Outlines"), outlineRef);
 
const out = await doc.save();
await writeFile("report-with-bookmarks.pdf", out);

This is verbose because the PDF outline is a doubly linked list of dictionaries, each pointing at Prev, Next, and a destination page. For nested bookmarks you add First, Last, and Parent references to build the tree. If you need this often, wrap it in a helper that takes a flat list of { title, pageIndex, level } and builds the linkage.

The page index you pass to the outline must match the page where the section actually lands in the rendered PDF. If you do not know that index ahead of time, render once, parse the PDF to find the page of each anchor, then write the outline. This is the main reason sidebar bookmarks are harder than HTML anchor links.

If you want both a visible contents page and a sidebar outline, render the body with PDF4.dev (HTML TOC included), then run the pdf-lib outline pass on the returned PDF. You can also Merge PdfTry it free a separately rendered cover or contents page in front of the body if you build the TOC page on its own.

The single most common failure is a mismatch between the link href and the element id, usually introduced by template rendering or manual editing. Generate ids and links from one source, and verify the targets exist in the final HTML, not the source template.

A short checklist that catches the majority of broken-TOC bugs:

  • Every href="#x" has a matching id="x" in the rendered output, not just the template.
  • Ids are unique. Two elements sharing an id make the jump land on whichever the renderer sees first.
  • Ids survive slugification. "Q&A" and "Q and A" can slugify differently in TOC vs heading.
  • Sections are not removed by conditional logic while their TOC entry stays. An empty {{#if}} block can drop a section but keep its link.
  • The id is on a block-level element that has a real position. An id on an inline <span> inside a floated element can land oddly.

For a deep dive on how anchor links and external URLs both turn into clickable PDF annotations, see clickable links in PDF from HTML. For the page-break and print-layout rules that decide where each section lands, see the CSS print styles guide.

Which option should you choose?

Match the navigation type to how your readers actually move through the document. The recommendation depends on whether they read on screen, print on paper, or scan long reports via a sidebar.

ScenarioRecommended approachWhy
On-screen reading, short to medium docsHTML anchor TOC onlyClickable links cover navigation; zero post-processing
Long reports, ebooks, manualsHTML TOC plus pdf-lib outlineReaders expect the sidebar tree for fast jumping
Printed paper with accurate page numbersPrint-CSS engine (WeasyPrint, Prince)These support target-counter for real page numbers
Adding navigation to an existing PDFpdf-lib outlineNo HTML to re-render; edit the PDF structure directly
No infrastructure to maintainPDF4.dev render APIHosted Chromium, clickable TOC works out of the box

The default for most web and SaaS use cases: render HTML to PDF with a Chromium engine and rely on anchor links. It is the simplest path, needs no PDF library, and the TOC is clickable the moment the document opens. Reach for pdf-lib outline writing only when readers need the sidebar tree, and reach for a print-CSS engine only when a printed paper TOC with computed page numbers is a hard requirement.

If you want the clickable TOC without running and patching a headless browser yourself, PDF4.dev renders the HTML server-side and returns a PDF whose anchor links already jump. You keep full control of the HTML and CSS; the hosting and Chromium upkeep are not your problem.

Free tools mentioned:

Merge PdfTry it freeHtml To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.