Send out a manual or report of a few hundred pages and the complaint is rarely about the content — it's that readers can't find the chapter they want. They want a page-numbered list of chapters up front, and one click to jump there. Many PDFs are generated without one, so readers are left to the scrollbar or in-document search.
This article shows how to create a table of contents page and add navigation to its entries with Spire.PDF for JavaScript. It loads, edits and saves PDF documents directly in the browser through WebAssembly, reading and writing files through a virtual file system (VFS), so everything runs locally with no backend.
Two core features are covered:
For installation and project setup, see Integrate Spire.PDF for JavaScript in a React Project. The examples below assume Spire.PDF is installed and the WebAssembly module is initialized.
Create a Table of Contents Page
The contents page has to land at a specific position in the document. Pages.Insert({ index }) inserts a page and returns it, and the title, chapter entries, leader dots and page numbers are all drawn on that page with Canvas.DrawString. Each entry advances horizontally by its text width, and leader dots fill the gap from the end of the title to the start of the page number.
function App() {
const createTocPage = async () => {
// Get the Spire.PDF WASM module
const pdfModule = window.wasmModule?.spirepdf;
// Check whether the module is ready
if (!pdfModule) {
alert('Spire.PDF is not ready yet');
return;
}
// Load the PDF to process into the VFS
const inputFileName = 'Chapter_Document.pdf';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/data/`);
// Create a PdfDocument object and load the PDF
let doc = new pdfModule.PdfDocument();
doc.LoadFromFile(inputFileName);
// Insert the contents page after the cover; the body pages shift down by one
const tocPage = doc.Pages.Insert({ index: 1 });
// Fonts for the title and the entries, using the built-in Helvetica (no font file to load)
const titleFont = new pdfModule.PdfFont({ fontFamily: pdfModule.PdfFontFamily.Helvetica, size: 20, style: pdfModule.PdfFontStyle.Bold });
const entryFont = new pdfModule.PdfFont({ fontFamily: pdfModule.PdfFontFamily.Helvetica, size: 14 });
const centerFormat = new pdfModule.PdfStringFormat({ alignment: pdfModule.PdfTextAlignment.Center });
// Draw the centered contents title
const title = 'Contents';
tocPage.Canvas.DrawString({
s: title,
font: titleFont,
brush: pdfModule.PdfBrushes.get_Black(),
point: new pdfModule.PointF(tocPage.Canvas.ClientSize.Width / 2, 50),
format: centerFormat
});
// Chapter titles and their page numbers after the contents page is inserted
const chapters = [
{ title: 'Chapter 1 Overview', page: 3 },
{ title: 'Chapter 2 Architecture', page: 4 },
{ title: 'Chapter 3 Deployment', page: 5 },
{ title: 'Chapter 4 Maintenance', page: 6 }
];
const width = tocPage.Canvas.ClientSize.Width;
let y = 110;
for (const chapter of chapters) {
// Entry text
const titleSize = entryFont.MeasureString({ text: chapter.title });
tocPage.Canvas.DrawString({ s: chapter.title, font: entryFont, brush: pdfModule.PdfBrushes.get_Black(), x: 40, y: y });
// Right-aligned page number
const pageText = chapter.page.toString();
const pageSize = entryFont.MeasureString({ text: pageText });
tocPage.Canvas.DrawString({ s: pageText, font: entryFont, brush: pdfModule.PdfBrushes.get_Black(), x: width - 40 - pageSize.Width, y: y });
// Leader dots: fill from the end of the entry to the start of the page number
const dotStart = 40 + titleSize.Width + 6;
const dotEnd = width - 40 - pageSize.Width - 6;
for (let x = dotStart; x < dotEnd; x += 6) {
tocPage.Canvas.DrawString({ s: '.', font: entryFont, brush: pdfModule.PdfBrushes.get_Gray(), x: x, y: y });
}
y += 24;
}
// Define the output file name and save
const outputFileName = 'Document-with-TOC.pdf';
doc.SaveToFile(outputFileName);
doc.Close();
// Read the generated file from the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Create a Table of Contents Page</h1>
<button id="btn-1" onClick={createTocPage}>
Create TOC
</button>
</div>
);
}
export default App;
The document with a contents page: the page after the cover lists each chapter with its page number

Add Navigation to Table of Contents Entries
Once the contents page is drawn, each entry is still just a line of text. To make an entry clickable, cover it with a PdfActionAnnotation hit area and attach a PdfGoToAction carrying a PdfDestination that names the target page. There is no need to derive the hit area's position from line spacing — search the entry's text on the contents page with PdfTextFinder, and the rectangle it returns is where that line actually sits on the page, ready to use as the hit area.
function App() {
const addTocNavigation = async () => {
// Get the Spire.PDF WASM module
const pdfModule = window.wasmModule?.spirepdf;
// Check whether the module is ready
if (!pdfModule) {
alert('Spire.PDF is not ready yet');
return;
}
// Load the document generated in the previous step
const inputFileName = 'Document-with-TOC.pdf';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/data/`);
let doc = new pdfModule.PdfDocument();
doc.LoadFromFile(inputFileName);
// The contents page is page 2 of the document (index 1)
const tocPage = doc.Pages.get_Item(1);
// The entry text and the page each one should jump to
const chapters = [
{ title: 'Chapter 1 Overview', page: 3 },
{ title: 'Chapter 2 Architecture', page: 4 },
{ title: 'Chapter 3 Deployment', page: 5 },
{ title: 'Chapter 4 Maintenance', page: 6 }
];
// Search the contents page by keyword
const finder = new pdfModule.PdfTextFinder(tocPage);
for (const chapter of chapters) {
const found = finder.Find(chapter.title);
if (found.length === 0) {
continue;
}
// Define the hit area based on the keyword position
const lineBounds = found.get(0).Bounds[0];
const bounds = new pdfModule.RectangleF({
location: new pdfModule.PointF(0, lineBounds.Y),
size: new pdfModule.SizeF({ width: tocPage.Canvas.ClientSize.Width, height: lineBounds.Height })
});
// The jump target is the chapter's page, aligned to the top-left corner of the body
const targetPage = doc.Pages.get_Item(chapter.page - 1);
const destination = new pdfModule.PdfDestination({
page: targetPage,
location: new pdfModule.PointF(0, 0)
});
// Attach the jump action and set the border width to 0
const action = new pdfModule.PdfActionAnnotation(bounds, new pdfModule.PdfGoToAction({ destination }));
action.Border = new pdfModule.PdfAnnotationBorder({ borderWidth: 0 });
tocPage.Annotations.Add(action);
}
// Define the output file name and save
const outputFileName = 'Clickable-TOC.pdf';
doc.SaveToFile(outputFileName);
doc.Close();
// Read the generated file from the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Add Navigation to Table of Contents Entries</h1>
<button id="btn-2" onClick={addTocNavigation}>
Add Navigation
</button>
</div>
);
}
export default App;
Clicking a chapter title in the contents page jumps to that page

FAQ
Table of contents page numbers don't match the actual pages
Cause: the contents page is inserted into the original document, so every page after the insertion point shifts down by one. If the page numbers keep the order from before the insertion, they will be off by one across the board.
Solution: write the page numbers as they appear after the insertion. For example, if the cover was page 1 and chapter 1 was page 2, then after inserting the contents page after the cover, chapter 1 falls on page 3, and that is what the contents should list.
Clicking a table of contents entry jumps to the wrong chapter, or does nothing
Cause: the entry is drawn with Canvas.DrawString, but the hit area has to be given in page coordinates. Deriving it from the drawing y plus the line spacing means any mismatch in font metrics, line spacing or page margins accumulates row by row, so the click lands on a different entry — or on nothing at all.
Solution: don't derive it — search the contents page for the entry's text (the keyword) and use the rectangle that comes back. PdfTextFinder already returns page coordinates, so no top margin has to be added; get page with doc.Pages.get_Item(...) so it is a real page object:
const finder = new pdfModule.PdfTextFinder(tocPage);
const found = finder.Find(chapter.title);
const lineBounds = found.get(0).Bounds[0];
const bounds = new pdfModule.RectangleF({
location: new pdfModule.PointF(0, lineBounds.Y),
size: new pdfModule.SizeF({ width: tocPage.Canvas.ClientSize.Width, height: lineBounds.Height })
});
const targetPage = doc.Pages.get_Item(chapter.page - 1);
const action = new pdfModule.PdfActionAnnotation(bounds, new pdfModule.PdfGoToAction({ destination }));
Get a Free License
To remove the evaluation message from the generated documents, or to get rid of the function limitations, please contact sales for a temporary license valid for 30 days.
