Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Direct answer: Compile the Handlebars source into a complete HTML string, give that string to a Puppeteer page, wait for the page’s fonts and image requests, then call page.pdf() with the media, background, and page-size options your design requires. CSS and images are not part of Handlebars compilation; Chromium must be able to resolve them when it renders the resulting HTML.

The complete pipeline

  1. Install Handlebars and Puppeteer.
  2. Compile the template with Handlebars.compile() and render it with your data.
  3. Create a Chromium page and load the rendered HTML.
  4. Wait for navigation, fonts, and images.
  5. Choose print or screen media, then generate the PDF.

Handlebars produces HTML; Puppeteer renders that HTML in Chromium. Keep the template a complete document (or a complete fragment with all required styles) so the browser has predictable input.

Install the dependencies

npm install handlebars puppeteer

Yarn users can run yarn add handlebars puppeteer. The CommonJS form used below follows Handlebars’ documented installation pattern.

Build a template that contains CSS and images

Use an inline <style> block for CSS that must travel with the HTML, or use a stylesheet URL that the Chromium process can reach. Image src values must also resolve inside the deployment environment.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const templateSource = `<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    @page { size: A4; margin: 18mm; }
    body { font-family: Arial, sans-serif; color: #1f2937; }
    .hero { background: #0f766e; color: white; padding: 24px; }
    .hero img { width: 140px; height: auto; display: block; }
    @media print {
      .screen-only { display: none !important; }
    }
  </style>
</head>
<body>
  <section class="hero">
    <img src="{{logoUrl}}" alt="{{company}} logo">
    <h1>{{title}}</h1>
  </section>
  <p>Prepared for {{customer}}.</p>
  <ul>
    {{#each items}}<li>{{this}}</li>{{/each}}
  </ul>
</body>
</html>`;

Handlebars escapes normal interpolations, which is safer for user data. Use triple-stash expressions only for HTML you intentionally sanitize and trust. If you use a local image, a file:// URL or data URL can be appropriate, but validate the path and Chromium security settings in your target container. Absolute HTTPS URLs are usually simpler for a hosted asset; private URLs may require request interception or signed access.

Compile and render the HTML

const Handlebars = require('handlebars');

const template = Handlebars.compile(templateSource);
const html = template({
  company: 'Example Co',
  title: 'Quarterly report',
  customer: 'Ada Lovelace',
  logoUrl: 'https://example.com/assets/logo.png',
  items: ['Revenue increased', 'Churn decreased']
});

Handlebars.compile() returns a render function. Calling it with a data object substitutes variables and expands blocks such as #each. For high-volume deployments, Handlebars also supports precompilation; pair a precompiled template with the same runtime version used to execute it.

Load the rendered page in Puppeteer

const puppeteer = require('puppeteer');

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: 'networkidle0' });
  // PDF generation follows here.
} finally {
  await browser.close();
}

Use networkidle0 when you need all network requests to become idle, or networkidle2 when a page legitimately keeps one or two connections open. The exact behavior can vary with the Puppeteer version, so verify it in your deployment.

Wait explicitly for images

Network-idle navigation is useful, but image readiness can still be deployment-specific. Before printing, wait until every image reports completion and a natural width.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
await page.waitForFunction(() => {
  return Array.from(document.images).every(img => img.complete && img.naturalWidth > 0);
});

This rejects the wait when an image failed. If broken images are acceptable, test img.complete alone and log failed URLs instead. A selector-specific wait is useful for lazy-loaded content; trigger scrolling or use the page’s own loading behavior before the check.

Wait for fonts

Puppeteer’s PDF API waits for fonts by default through waitForFonts: true. You can also make readiness explicit:

await page.evaluate(async () => { await document.fonts.ready; });

Choose print or screen CSS

Puppeteer’s page.pdf() generates a PDF with the print CSS media type by default. Put print-only rules in @media print. If your design depends on screen media queries, switch before creating the PDF:

await page.emulateMediaType('screen');

Do not switch media casually: screen and print rules can change colors, visibility, dimensions, and pagination. Decide which stylesheet is authoritative for the document, then test both the browser view and PDF.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Generate the PDF with reliable options

await page.pdf({
  path: 'output.pdf',
  printBackground: true,
  preferCSSPageSize: true,
  waitForFonts: true
});

printBackground is false by default, so colored sections, background images, and shaded table rows disappear unless you enable it. Use one of these sizing strategies:

Strategy When to use it Important option
Named format Standard paper such as A4 or Letter format: 'A4'
Explicit dimensions Labels, receipts, or a custom sheet width and height
CSS page size The stylesheet owns pagination preferCSSPageSize: true

When @page { size: ... } defines the layout, preferCSSPageSize: true lets CSS take precedence over a conflicting format or dimension. You can additionally set margin, landscape: true, scale, and pageRanges.

await page.pdf({
  path: 'invoice.pdf',
  format: 'A4',
  landscape: false,
  margin: { top: '18mm', right: '18mm', bottom: '18mm', left: '18mm' },
  printBackground: true,
  preferCSSPageSize: true,
  pageRanges: '1-3',
  scale: 1,
  waitForFonts: true
});

Complete runnable example

const Handlebars = require('handlebars');
const puppeteer = require('puppeteer');

const source = `<!doctype html>
<html><head><meta charset="utf-8">
<style>
@page { size: A4; margin: 16mm; }
body { font-family: Arial, sans-serif; }
.banner { background: #2563eb; color: #fff; padding: 20px; }
.banner img { max-width: 120px; }
</style></head>
<body>
<div class="banner"><img src="{{logoUrl}}" alt="Logo"><h1>{{title}}</h1></div>
{{#each rows}}<p>{{this}}</p>{{/each}}
</body></html>`;

(async () => {
  const template = Handlebars.compile(source);
  const html = template({
    logoUrl: 'https://example.com/logo.png',
    title: 'Monthly statement',
    rows: ['First item', 'Second item']
  });
  const browser = await puppeteer.launch();
  try {
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: 'networkidle0' });
    await page.waitForFunction(() => Array.from(document.images)
      .every(img => img.complete && img.naturalWidth > 0));
    await page.evaluate(async () => { await document.fonts.ready; });
    await page.pdf({
      path: 'statement.pdf',
      printBackground: true,
      preferCSSPageSize: true,
      waitForFonts: true
    });
  } finally {
    await browser.close();
  }
})();

CSS and image troubleshooting

CSS appears unstyled

  • Check that external stylesheet URLs are reachable from the machine running Chromium.
  • Inspect the generated HTML, not just the Handlebars source, for missing variables or malformed tags.
  • Remember that PDF rendering uses print media unless you call emulateMediaType('screen').
  • For deterministic output, inline critical CSS or serve assets from a stable, deployment-correct URL.

Backgrounds are missing

Set printBackground: true. Also check whether the background is applied by a print rule that is overridden or hidden.

Images are blank or broken

  • Confirm the final src is absolute or otherwise valid in the container.
  • Wait for completion and verify naturalWidth.
  • Check TLS, authentication, hotlink protection, redirects, and the image response’s content type.
  • Embed critical small images as data URLs when deployment access is unreliable; test the resulting HTML because large data URLs increase memory use.

Fonts fall back

Ensure the font files are reachable and that their CSS is loaded before printing. Keep waitForFonts: true and await document.fonts.ready; inspect the browser console and network responses for blocked font requests.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

PDF pagination is wrong

Choose one sizing authority. If CSS defines @page, enable preferCSSPageSize; otherwise use format or explicit dimensions. Review margins, break-inside, break-before, and oversized images that force unexpected page breaks.

The page never becomes idle

Analytics, WebSockets, polling, and long-lived requests can prevent an idle condition. Use a realistic wait strategy, wait for a known selector, or disable nonessential requests with request interception. Always retain a timeout and produce diagnostics rather than waiting indefinitely.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Runtime, reliability, and cost considerations

Compile templates once when the source is unchanged, and reuse a browser process where your service model allows it; launching Chromium for every request adds startup work. Isolate untrusted template data, set request and navigation timeouts, and close pages and browsers in finally blocks. Record the generated HTML, failed asset URLs, media type, page size, and Puppeteer version when diagnosing differences between environments.

There are no universal performance numbers for this pipeline: rendering time depends on Chromium startup, template size, network latency, image dimensions, font loading, JavaScript, and PDF complexity. Cache immutable assets and avoid unnecessary full-page resources, but do not remove a request that your document needs.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server when you need a rendered page without maintaining Puppeteer infrastructure. It accepts a URL and returns PNG, JPEG, WebP, or PDF. Before capture it accepts the cookie or consent banner like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. Its MCP tools—take_screenshot, get_page_info, and capture_pdf—work with Claude, Cursor, and other MCP clients.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the ScreenshotNeo documentation for all options, including full-page capture, CSS-selector elements, dark mode, device presets, retina scale, PDF paper and page ranges, custom CSS or JavaScript, clicks, waits, blocked resources, headers, cookies, user agents, timezone, geolocation, transparent backgrounds, resizing, TTL caching, signed image links, async webhooks, bulk capture of up to 100 URLs per call, usage reporting, and the OpenAPI specification. Parameter names used by other screenshot APIs also work to ease migration.

Python

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)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

The Free plan includes 1,000 shots each month with no card. Paid plans start at $5 for 3,000 shots; yearly billing gives two months free, and every feature is on every plan. Sign up for ScreenshotNeo to start with the free allowance.

FAQ

Can I use a Handlebars partial for the document head?

Yes. Register the partial before compilation, then include it in the template; ensure the expanded result still contains the styles and metadata the browser needs.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Should I use networkidle0 or networkidle2?

Use networkidle0 for a page expected to finish all requests. Choose networkidle2 when a small number of persistent connections are normal, then add explicit waits for the content that determines correctness.

Does page.pdf() print CSS backgrounds automatically?

No. Enable printBackground: true; its default is false.

Frequently Asked Questions

Can Handlebars compile CSS and image files itself?

No. It substitutes data into text. Puppeteer and Chromium load the resulting CSS, fonts, and images.

Why does my PDF differ from the browser preview?

PDF generation starts in print media. Print rules, page size, margins, and background settings can all change the result.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.