Get started
How to add clickable links to a PDF generated from HTML

How to add clickable links to a PDF generated from HTML

Clickable links work automatically when you convert HTML to PDF with Chromium. Add external links, internal anchors, a table of contents and bookmarks.

9 min read

Clickable links work automatically when you convert HTML to PDF with a browser engine. Any <a href> in your HTML becomes a real link annotation in the PDF: external URLs open in a browser, and internal anchor links jump to a page inside the same document. The one thing headless Chromium does not do through the standard API is build the sidebar outline (the bookmark tree); you add that in a quick post-processing step. If your links come out dead, the cause is almost always an image-based converter that screenshots the page instead of rendering it.

This guide covers external links, internal anchor links, a clickable table of contents, the bookmark outline, and why links break in some tools. You can test any HTML right now with the free Html To PdfTry it free tool, which renders with the same engine described below.

Browser engines keep links clickable; image-based converters do not. A link in a PDF is a vector annotation tied to a rectangle on the page, not a colored pixel. Engines that render the real DOM (Chromium, WeasyPrint, wkhtmltopdf) write those annotations. Converters that rasterize the page to an image (html2pdf.js, and any "screenshot to PDF" approach) produce a flat picture where nothing is clickable.

EngineExternal linksInternal anchor linksSidebar bookmarks (outline)
Headless Chromium (Playwright / Puppeteer)Yes, automaticYes, automaticNo through standard API, post-process
PDF4.dev API (Chromium)Yes, automaticYes, automaticPost-process
WeasyPrintYes, automaticYes, automaticYes, from headings via CSS
wkhtmltopdf (deprecated)Yes, automaticYes, with a flagYes, built-in outline
html2pdf.js (rasterizer)No, image onlyNo, image onlyNo
jsPDFManual via doc.link()Manual onlyManual only

Internal anchor support in wkhtmltopdf depends on the --enable-internal-links flag. wkhtmltopdf is no longer maintained, so treat it as a legacy option. See why we wrote off wkhtmltopdf.

The takeaway: pick a browser-based engine if links matter. The rest of this guide uses headless Chromium, the engine behind Playwright, Puppeteer, and PDF4.dev.

Write a standard anchor with an absolute URL, then render the HTML with a browser engine. The engine converts the anchor into a clickable link annotation pointing at that URL.

<p>
  Questions? Email us or read the
  <a href="https://yourdomain.com/docs">documentation</a>.
</p>

Use absolute URLs (starting with https://) for every external link. A PDF has no base URL, so a relative href like /docs has nothing to resolve against once the document is standalone, and the link will not open. This is the single most common reason an external link "renders but does nothing" in a generated PDF.

If your template builds links from data, make the value absolute before it reaches the HTML. In a Handlebars template that means passing a full URL in the data object, for example {{invoice_url}} set to https://yourdomain.com/i/INV-001, not a path fragment.

Give the target element an id, then link to it with a hash. Headless Chromium converts a same-document hash link into an internal GoTo annotation that jumps to the target's page when the reader clicks it.

<a href="#terms">Jump to terms</a>
 
<!-- ...many pages later... -->
 
<section id="terms">
  <h2>Terms and conditions</h2>
</section>

This is the mechanism behind cross-references in long documents: "see appendix B", "back to top", footnote markers, and contents entries. The id must be unique on the page and the href must match it exactly, including case. No CSS or JavaScript is required; the link is resolved at render time from the document structure.

Internal links survive merging if you combine PDFs carefully, but only within each source document. If you merge two PDFs with the free Merge PdfTry it free tool, a link in document A still points inside document A's pages, not into document B.

How do I build a clickable table of contents?

Add an id to every heading, then list those headings as anchor links pointing at the ids. Each entry becomes an internal jump, so the reader taps a line in the contents and lands on the section. This is the same internal-link mechanism applied to a list.

<nav class="toc">
  <a href="#summary">1. Summary</a>
  <a href="#findings">2. Findings</a>
  <a href="#appendix">3. Appendix</a>
</nav>
 
<h2 id="summary">1. Summary</h2>
<!-- ... -->
<h2 id="findings">2. Findings</h2>
<!-- ... -->
<h2 id="appendix">3. Appendix</h2>

If you generate the heading ids dynamically, slugify the heading text so the id is stable and URL-safe. The contents links and the heading ids must use the same slug. For control over where sections start on a fresh page, pair this with break-before: page in your print CSS, covered in the CSS print styles guide.

A clickable table of contents is page-content navigation. It is separate from the PDF outline (the viewer's sidebar), which is the next section.

The usual culprit is an image-based renderer. Run through these causes in order, most common first.

SymptomCauseFix
No link is clickable anywhereRenderer rasterizes the page (html2pdf.js, screenshot tools)Switch to a browser engine (Playwright, Puppeteer, PDF4.dev)
External links do nothingRelative href like /page instead of an absolute URLUse https://... absolute URLs
Internal links do nothinghref hash does not match any element idMake the id and the href match exactly
Links missing only in print outputA @media print rule hides or restyles the anchorCheck your print CSS and display rules
Link area is offset from the textCustom transforms or absolute positioning move the boxAvoid transforms on the anchor itself

Image-based conversion is the dominant cause. html2pdf.js wraps html2canvas, which paints the DOM onto a canvas and exports an image; the PDF is then a wrapper around that image, so there is no anchor to click. If you need clickable links, an image pipeline is the wrong tool.

How do I add the PDF outline (sidebar bookmarks)?

The PDF outline is the navigation tree a PDF viewer shows in its sidebar, and headless Chromium does not generate it through the standard Playwright or Puppeteer API. Chromium can emit an outline through the DevTools Page.printToPDF parameter generateDocumentOutline (Chrome 110 and later), but page.pdf() in Playwright and Puppeteer does not pass it through. So a clickable table of contents on the page works automatically, while the sidebar outline needs one extra step.

Two reliable approaches:

First, post-process the finished PDF. Open it with pdf-lib in Node.js or PyMuPDF in Python and attach outline entries to the pages where each heading starts. This works regardless of which engine produced the PDF, and it is the route to take when you only have the finished file.

Second, render with an engine that builds the outline for you. WeasyPrint generates bookmarks from your headings automatically and lets you tune them with the CSS bookmark-level property, documented in the WeasyPrint bookmarks reference. The trade-off is WeasyPrint's weaker support for modern CSS layout compared with Chromium, compared in Playwright vs WeasyPrint.

The same HTML produces clickable links across Node.js, Python, and the PDF4.dev API, because all three drive headless Chromium. The anchors need no special markup.

import { chromium } from "playwright";
 
const html = `
  <a href="#section-2">Go to section 2</a>
  <a href="https://pdf4.dev">External link</a>
  <h2 id="section-2">Section 2</h2>
`;
 
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "load" });
// Both the internal anchor and the external URL are clickable
await page.pdf({ path: "out.pdf", printBackground: true });
await browser.close();

To add a bookmark outline after rendering, attach outline entries to the finished PDF with pdf-lib:

import { PDFDocument, PDFName, PDFArray } from "pdf-lib";
import { readFile, writeFile } from "node:fs/promises";
 
// pdf-lib has no high-level outline API, so you build the outline
// dictionary by hand and link each entry to a page reference.
const pdf = await PDFDocument.load(await readFile("out.pdf"));
const pages = pdf.getPages();
// Map heading -> page index, then create /Outlines entries pointing at
// each page. Many teams keep this in a small helper module.
// See the pdf-lib issues for full outline examples.
await writeFile("out-with-outline.pdf", await pdf.save());

DIY rendering works, until production load

A single Playwright script that keeps links clickable is straightforward. The cost shows up at scale, and it is operational, not about capability. Headless Chromium adds roughly 280MB to a Docker image, a cold browser launch adds hundreds of milliseconds per render unless you pool warm instances, and concurrent renders compete for memory in ways that crash workers under spikes. Serverless runtimes cap binary size and execution time, which forces awkward workarounds for the Chromium binary.

PDF4.dev runs that Chromium pipeline as an API, so your <a> tags stay clickable without you operating the browser fleet. The same HTML you tested in the Html To PdfTry it free tool renders through POST /api/v1/render, links and all.

Internal anchor links and external URLs in your PDF4.dev templates are clickable in the output with no extra flags. Build a template, add <a href="#section"> links, and start generating PDFs free.

The decision is not "which engine is technically better" since Playwright, Puppeteer, and PDF4.dev share the same Chromium engine. The question is whether you want to run and patch that engine yourself. For more on that trade-off, read the complete HTML to PDF guide and the Node.js generation walkthrough.

Free tools mentioned:

Html To PdfTry it freeWebpage To PdfTry it freeMerge PdfTry it free

Start generating PDFs

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