ScreenshotNeo

BlogHTML to image & PDF

How to Add Page Borders to PDFs With Puppeteer and Handlebars

Build bordered PDFs with Handlebars and Puppeteer, including print CSS, backgrounds, page breaks, margins, troubleshooting, and production patterns.

By the ScreenshotNeo team1 October 20269 min read

Put the border on the HTML content that Puppeteer prints, then call page.pdf(). Do not rely on @page { border: ... }: the CSS page box does not provide a generally supported border property. For a document whose pages are known in advance, render one bordered page container per page. For naturally flowing content, use a bordered wrapper only when a border spanning page fragments is acceptable, then inspect the generated PDF for breaks, clipping, and overlap.

Puppeteer prints with the print media type, and PDF background graphics are disabled unless you set printBackground: true. Handlebars escapes normal expressions by default, so keep untrusted values in ordinary {{value}} expressions and avoid triple-stash output unless the HTML is trusted.

1. Install the dependencies

mkdir bordered-pdf
cd bordered-pdf
npm init -y
npm install puppeteer handlebars

Puppeteer downloads a compatible browser during installation. If your deployment supplies its own Chromium binary, configure executablePath in the launch options.

2. Create a Handlebars template with a page border

The most predictable layout uses a separate .pdf-page element for each physical page. Each element has a fixed paper-sized height, margins inside the page, and a border. The template below intentionally creates two pages so the page-break behavior is visible.

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    @page {
      size: A4;
      margin: 0;
    }

    * { box-sizing: border-box; }

    html, body {
      margin: 0;
      padding: 0;
      background: #eceff3;
      font-family: Arial, sans-serif;
      color: #17202a;
    }

    .pdf-page {
      width: 210mm;
      min-height: 297mm;
      padding: 18mm;
      border: 1.2mm solid #17202a;
      background: #ffffff;
      page-break-after: always;
      break-after: page;
    }

    .pdf-page:last-child {
      page-break-after: auto;
      break-after: auto;
    }

    h1, h2 { margin-top: 0; }
    p { line-height: 1.5; }
  </style>
</head>
<body>
  {{#each pages}}
    <section class="pdf-page">
      <h1>{{title}}</h1>
      <p>{{intro}}</p>
      {{#if items}}
        <h2>Items</h2>
        <ul>
          {{#each items}}
            <li>{{this}}</li>
          {{/each}}
        </ul>
      {{/if}}
    </section>
  {{/each}}
</body>
</html>

The @page rule sets paper size and removes the browser’s outer print margin. The visible border belongs to .pdf-page, which is ordinary rendered content. Keep the inner padding large enough that text and graphics do not touch the border.

3. Render the template and write the PDF

const fs = require('node:fs/promises');
const Handlebars = require('handlebars');
const puppeteer = require('puppeteer');

const templateSource = `
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    @page { size: A4; margin: 0; }
    * { box-sizing: border-box; }
    html, body { margin: 0; padding: 0; background: #eceff3; }
    body { font-family: Arial, sans-serif; color: #17202a; }
    .pdf-page {
      width: 210mm;
      min-height: 297mm;
      padding: 18mm;
      border: 1.2mm solid #17202a;
      background: #fff;
      page-break-after: always;
      break-after: page;
    }
    .pdf-page:last-child { page-break-after: auto; break-after: auto; }
    h1, h2 { margin-top: 0; }
    p { line-height: 1.5; }
  </style>
</head>
<body>
  {{#each pages}}
    <section class="pdf-page">
      <h1>{{title}}</h1>
      <p>{{intro}}</p>
      {{#if items}}
        <ul>{{#each items}}<li>{{this}}</li>{{/each}}</ul>
      {{/if}}
    </section>
  {{/each}}
</body>
</html>`;

const data = {
  pages: [
    {
      title: 'Quarterly report',
      intro: 'Revenue and operating highlights for the first quarter.',
      items: ['New subscriptions', 'Renewal rate', 'Operating costs']
    },
    {
      title: 'Appendix',
      intro: 'Supporting notes and definitions.'
    }
  ]
};

(async () => {
  const template = Handlebars.compile(templateSource);
  const html = template(data);
  const browser = await puppeteer.launch({ headless: true });

  try {
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: 'networkidle0' });
    await page.pdf({
      path: 'bordered-report.pdf',
      format: 'A4',
      printBackground: true,
      preferCSSPageSize: true,
      margin: { top: '0', right: '0', bottom: '0', left: '0' }
    });
  } finally {
    await browser.close();
  }
})();

Save this as generate.js and run node generate.js. The page.pdf() API accepts a paper format or explicit dimensions, margins, orientation, page ranges, and other PDF options. When both format and width/height are supplied, format takes priority. preferCSSPageSize: true lets the CSS @page size take priority. See Puppeteer’s PDFOptions documentation.

4. Choose a border strategy

Known, manually paginated pages

Use one bordered element per page, as in the example. This gives you the clearest control over border repetition. Split long data into page-sized groups before rendering. Leave room for headings, tables, and variable-length text because CSS content height can change after fonts load.

One flowing document

.document {
  margin: 12mm;
  padding: 12mm;
  border: 1px solid #17202a;
  background: #fff;
}

This creates one border around the document’s rendered content. It does not promise a new complete border on every physical PDF page. Fragmentation across page breaks depends on the layout and browser. Inspect the PDF before treating this as a per-page design.

Page-like sections with automatic breaks

.page-section {
  border: 1px solid #17202a;
  padding: 15mm;
  break-inside: avoid;
  page-break-after: always;
}

.page-section:last-child {
  page-break-after: auto;
}

break-inside: avoid can keep a section together when it fits, but it cannot keep content on one page when the content is taller than the page. Use explicit pagination for strict guarantees.

5. Make borders and colors appear in the PDF

Puppeteer’s PDF output does not print background graphics by default. Set printBackground: true when the border design depends on a background color, gradient, image, or other background painting. Borders themselves are foreground drawing, but enabling backgrounds is usually necessary when the page design uses a colored canvas or decorative layers.

await page.pdf({
  path: 'report.pdf',
  format: 'A4',
  printBackground: true
});

Print styling can adjust colors. Puppeteer’s documentation recommends -webkit-print-color-adjust: exact when the design requires the browser to preserve specified colors.

html {
  -webkit-print-color-adjust: exact;
  print-color-adjust: exact;
}

6. Paper size, margins, orientation, and page ranges

Requirement Setting Example
Standard paper format format: 'A4'
Custom dimensions width, height width: '210mm', height: '297mm'
Physical printer margins margin margin: { top: '10mm', bottom: '10mm' }
Landscape output landscape landscape: true
Selected pages pageRanges pageRanges: '1-3'
CSS controls page size preferCSSPageSize preferCSSPageSize: true

Keep the CSS dimensions, border thickness, padding, and Puppeteer margins consistent. A nonzero PDF margin reduces the available content area and can make a border appear uneven relative to the paper edge.

7. Handlebars data safety and HTML escaping

Handlebars interpolates values from a template context and escapes normal expression output by default. That protects ordinary text fields from becoming markup. Do not use triple-stash expressions such as {{{html}}} for uncontrolled input: triple-stash bypasses escaping, and Handlebars does not escape JavaScript strings. See the Handlebars introduction and guide.

// Escaped text: safe for a user-provided title.
<h1>{{title}}</h1>

// Only use this when trustedHtml has been sanitized and is intentionally markup.
<div>{{{trustedHtml}}}</div>

Sanitize any intentionally allowed HTML before inserting it. Also validate URLs used in links or images, and avoid placing secrets in the template data.

8. Fonts, images, and asynchronous content

Wait for resources that affect layout before generating the PDF. networkidle0 is useful for static pages, but an application that keeps analytics or websocket requests open may never become idle. In that case, wait for a specific selector or a controlled delay instead.

await page.setContent(html, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => document.fonts.ready);
await page.waitForSelector('.pdf-page');
await page.waitForTimeout(250);
await page.pdf({ path: 'report.pdf', format: 'A4', printBackground: true });

For remote images, ensure the browser can reach the host and that the image has finished loading. Missing fonts can change line wrapping and push content across a page break, so package important fonts with the application or wait for them explicitly.

9. Troubleshooting

Symptom Likely cause Fix
Border is missing The selector does not match, CSS was not loaded, or the border color blends into the background. Inspect the HTML with page.screenshot(), verify the selector, and use a temporary high-contrast border.
Background color or decoration is missing printBackground defaults to false. Set printBackground: true.
Border appears only once A single flowing wrapper was fragmented across pages. Render separate page containers or accept a document-level border; do not assume fragmentation repeats the border.
Content overlaps the border Padding is too small, or the content exceeds the page’s usable height. Increase padding, reduce the border’s page content, or paginate the data.
Last page is blank A trailing element has page-break-after: always. Reset the final page to page-break-after: auto.
Border is clipped at the edge The border lies outside the printable content box or PDF margins are inconsistent. Set @page { margin: 0 }, align PDF margins, and keep the border inside the page dimensions.
Colors differ from the browser Print color adjustment changed the output. Use -webkit-print-color-adjust: exact and printBackground: true, then inspect the PDF.
Text moves to another page between runs Fonts or remote assets were not ready before capture. Wait for document.fonts.ready and required selectors; use deterministic local assets.
page.pdf() hangs The page never reaches the selected network-idle condition. Use domcontentloaded plus explicit waits instead of waiting forever for network idle.
Template values render as markup Triple-stash output was used. Use escaped {{value}} for text and sanitize any intentionally trusted HTML.

10. Verification checklist

  • Open the generated PDF, not only the browser preview.
  • Check the first, middle, and last pages for continuous borders.
  • Check long headings, tables, lists, and images at page boundaries.
  • Check portrait and landscape variants if both are supported.
  • Check output with and without background graphics.
  • Check fonts and remote images in the production environment.
  • Check that no content or border is clipped by the selected margins.

Exact continuity and clipping across page breaks are rendering outcomes. The CSS and Puppeteer APIs define the available controls, but they do not guarantee that every layout will fragment as a designer expects.

11. Performance, reliability, and cost

Launching a new browser for every document is simple but expensive in CPU and startup time. A service that generates many PDFs can keep a browser process alive and create a fresh page per job, while still closing pages and browsers on errors. Limit concurrency so simultaneous pages do not exhaust memory.

Use deterministic HTML and local assets where possible. Network-dependent fonts, images, analytics, and scripts increase latency and make page height less predictable. Set an application timeout around navigation and PDF generation, and retry only failures that are safe to repeat.

Measure the generated PDF size and page count in your own workload. Border CSS itself is usually negligible; browser startup, font loading, image decoding, and large DOM trees dominate generation time.

12. Or skip the browser setup

If you only need a screenshot or PDF of a web page, ScreenshotNeo provides a single API request. It removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. It also provides an MCP server for AI agents with take_screenshot, get_page_info, and capture_pdf.

For API parameters and PDF options, see the ScreenshotNeo documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo includes full-page capture, element selection, custom CSS and JavaScript, device and viewport settings, PDF paper and margin controls, waiting rules, request blocking, cookies and headers, caching, signed links, asynchronous jobs, bulk capture, and a usage API. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

13. FAQ

Can I put a border directly on @page?

Do not use it as a general solution. MDN documents @page for printed page dimensions, orientation, margins, and page targeting; page-box border properties are not supported by user agents. Put the border on rendered content or a layout element instead. See MDN’s @page reference.

Why does format: 'A4' ignore my CSS size?

The PDF format takes priority over width and height. Use preferCSSPageSize: true when the CSS @page size should control the output.

Should I use a fixed page height?

Use a fixed paper-sized container when you need a border on every physical page and can paginate content. For unconstrained flowing content, fixed heights can cause overflow and require additional pagination logic.

Does printBackground control borders?

It controls background graphics. A normal CSS border is foreground content, but designs often combine borders with background colors or images, so enable it when those backgrounds must appear.

How do I prove the border works after a dependency upgrade?

Generate representative PDFs and inspect page edges, page breaks, colors, fonts, and asset loading. Browser rendering details can change, so keep a small visual regression set for important document layouts.