Generating a PDF from a URL in Node.js comes down to three approaches in 2026: Puppeteer, Playwright, or a PDF API. This post gives you working code for all three, plus patterns for Express.js and serverless environments.
Method 1: Puppeteer
npm install puppeteer
import puppeteer from 'puppeteer';
async function urlToPDF(url) {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
// Navigate and wait for all network activity to settle
await page.goto(url, {
waitUntil: 'networkidle0', // stricter than networkidle2 — waits for 0 connections
timeout: 30000
});
// Wait for fonts to render (prevents FOUT)
await page.evaluate(() => document.fonts.ready);
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true, // include background colors/images
margin: {
top: '20mm',
bottom: '20mm',
left: '15mm',
right: '15mm'
},
// Optional: page ranges
// pageRanges: '1-3',
// Optional: header/footer
// displayHeaderFooter: true,
// headerTemplate: '',
// footerTemplate: ' of ',
});
await browser.close();
return pdfBuffer; // Buffer
}
// Save to file
import { writeFileSync } from 'fs';
const pdf = await urlToPDF('https://yourapp.com/invoice/123');
writeFileSync('./invoice.pdf', pdf);
Reuse the browser for multiple PDFs
import puppeteer from 'puppeteer';
class PDFService {
constructor() {
this.browser = null;
}
async init() {
this.browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
}
async generate(url, options = {}) {
if (!this.browser) await this.init();
const page = await this.browser.newPage();
try {
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
await page.evaluate(() => document.fonts.ready);
return await page.pdf({
format: options.format || 'A4',
printBackground: true,
margin: options.margin || { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
});
} finally {
await page.close(); // always close the page
}
}
async shutdown() {
if (this.browser) await this.browser.close();
}
}
// Singleton — reuse across requests
export const pdfService = new PDFService();
Method 2: Playwright
npm install playwright
npx playwright install chromium
import { chromium } from 'playwright';
async function urlToPDF(url) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
await page.evaluate(() => document.fonts.ready);
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }
});
await browser.close();
return pdfBuffer;
}
Playwright and Puppeteer have nearly identical PDF APIs. The difference: Playwright uses waitUntil: 'networkidle' instead of 'networkidle0', and provides browser contexts for better session isolation across concurrent requests.
Method 3: PDF API
Send a URL, get a PDF back. No Chromium binary, no memory management, works anywhere including serverless.
async function urlToPDF(url) {
const res = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: {
'X-Api-Key': process.env.SNAPAPI_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
format: 'pdf',
pdf_format: 'A4', // A4, Letter, Legal, Tabloid, A3
pdf_print_background: true, // include backgrounds
pdf_margin_top: '20mm',
pdf_margin_bottom: '20mm',
pdf_margin_left: '15mm',
pdf_margin_right: '15mm',
// Optional: wait for dynamic content
wait_for: '.invoice-ready', // CSS selector
delay: 1000, // additional wait in ms
}),
});
if (!res.ok) throw new Error(`PDF API error: ${res.status}`);
const { url: pdfUrl } = await res.json();
return pdfUrl; // CDN URL valid for 24h
}
// Usage
const pdfUrl = await urlToPDF('https://yourapp.com/invoice/123?token=abc');
// Redirect user to pdfUrl, or download and proxy the bytes
Download as Buffer
async function urlToPDFBuffer(url) {
const apiRes = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ url, format: 'pdf', pdf_format: 'A4', pdf_print_background: true }),
});
const { url: pdfUrl } = await apiRes.json();
const pdfRes = await fetch(pdfUrl);
return Buffer.from(await pdfRes.arrayBuffer());
}
Express.js: PDF Download Endpoint
import express from 'express';
import { authenticateRequest } from './middleware/auth.js';
const app = express();
// GET /invoices/:id/pdf — download as PDF
app.get('/invoices/:id/pdf', authenticateRequest, async (req, res) => {
const { id } = req.params;
// Generate a signed URL to your invoice render view
const token = generateSignedToken(id, req.user.id, { expiresIn: '5m' });
const invoiceUrl = `${process.env.APP_URL}/invoices/${id}/render?token=${token}`;
try {
// Option A: redirect to CDN URL (fast, no bandwidth cost)
const apiRes = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ url: invoiceUrl, format: 'pdf', pdf_format: 'A4', pdf_print_background: true }),
});
const { url: pdfUrl } = await apiRes.json();
return res.redirect(pdfUrl);
// Option B: proxy bytes (for Content-Disposition download)
// const pdfBytes = await fetch(pdfUrl).then(r => r.arrayBuffer());
// res.set({
// 'Content-Type': 'application/pdf',
// 'Content-Disposition': `attachment; filename="invoice-${id}.pdf"`,
// 'Content-Length': pdfBytes.byteLength,
// });
// res.send(Buffer.from(pdfBytes));
} catch (err) {
console.error('PDF generation failed:', err);
res.status(500).json({ error: 'PDF generation failed' });
}
});
Serverless: AWS Lambda
Puppeteer and Playwright don't run reliably on Lambda without complex layer setups (250MB+ Chromium layer, cold start penalty). The PDF API approach is the recommended pattern for serverless.
// Lambda handler (Node.js 20.x)
export const handler = async (event) => {
const { invoiceId, userId } = JSON.parse(event.body);
// Validate, generate signed URL, etc.
const invoiceUrl = `https://yourapp.com/invoices/${invoiceId}/render?token=${signedToken}`;
const res = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ url: invoiceUrl, format: 'pdf', pdf_format: 'A4', pdf_print_background: true }),
});
const { url: pdfUrl } = await res.json();
return {
statusCode: 302,
headers: { Location: pdfUrl },
body: '',
};
};
PDF Quality Tips
Whether you use Puppeteer, Playwright, or a PDF API, these settings consistently improve PDF quality:
- Always set
printBackground: true— background colors and images won't render without it - Use
@media printCSS — add print-specific styles to hide navbars, sidebars, and interactive elements - Wait for fonts —
document.fonts.readyprevents FOUT (flash of unstyled text) in PDFs - Avoid
networkidle2for PDFs — usenetworkidle0(Puppeteer) ornetworkidle(Playwright) for stricter loading - Set explicit viewport — PDF layout depends on the viewport width; match it to your design's breakpoint
- Use
page-break-inside: avoidon tables and cards to prevent awkward mid-element page breaks
When to Use Which
| Method | Best for | Avoid when |
|---|---|---|
| Puppeteer | Scripts, cron jobs, low volume | High concurrency, serverless, web APIs |
| Playwright | Self-hosted at low-medium volume | Lambda, Vercel Edge, memory-constrained |
| PDF API | Web apps, serverless, high volume | Airgapped environments (no outbound HTTP) |
Generate PDFs from any URL in Node.js with one fetch call
200 free PDFs/month. No Chromium binary, no memory leaks, works in Lambda and Cloudflare Workers.
Get your free API key →