Generating PDFs on the server is a common requirement for invoices, receipts, reports, and certificates. The tricky part is picking the right approach — headless browser rendering gives you full CSS control but requires Chrome; JSX-to-PDF libraries are lighter but have layout constraints; low-level libraries are fast but require manually positioning every element. In 2026, the right choice depends on template complexity and deployment environment.
What changed in 2026
- Playwright's
page.pdf() is now the preferred headless PDF method over Puppeteer — same API, better stability, and it's the same tool you use for e2e tests.
@react-pdf/renderer 4.x added support for flexbox gap and grid layout, covering the main layout gaps from earlier versions.
- Edge/serverless PDF generation matured:
@vercel/og handles image generation; for PDFs on serverless, pdfkit (no Chrome dependency) is the reliable choice.
wkhtmltopdf is deprecated — do not start new projects with it; use Playwright or Puppeteer instead.
- Chromium bundling in serverless deployments (via
@sparticuz/chromium) became standard practice for Next.js PDF routes on Vercel/Lambda.
Approach comparison
| Approach |
Full CSS |
Server req |
Bundle size |
Best for |
| Playwright / Puppeteer |
Yes |
Chrome |
~150 MB |
Rich templates, charts |
@react-pdf/renderer |
Partial |
No |
~2 MB |
Invoice-style templates |
pdfkit |
No (programmatic) |
No |
~1 MB |
Simple documents |
reportlab (Python) |
No (programmatic) |
No |
~5 MB |
Python backends |
| HTML email → PDF |
Partial |
Chrome |
~150 MB |
Existing email templates |
HTML-to-PDF with Playwright
// lib/pdf.ts
import { chromium } from 'playwright'
export async function htmlToPdf(html: string): Promise<Buffer> {
const browser = await chromium.launch()
const page = await browser.newPage()
await page.setContent(html, { waitUntil: 'networkidle' })
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
})
await browser.close()
return Buffer.from(pdf)
}
// app/api/invoice/[id]/route.ts
import { htmlToPdf } from '@/lib/pdf'
import { renderInvoiceHtml } from '@/lib/templates'
export async function GET(req: Request, { params }: { params: { id: string } }) {
const invoice = await getInvoice(params.id)
const html = renderInvoiceHtml(invoice)
const pdf = await htmlToPdf(html)
return new Response(pdf, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${params.id}.pdf"`,
},
})
}
React PDF (@react-pdf/renderer)
npm install @react-pdf/renderer
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
const styles = StyleSheet.create({
page: { padding: 40, fontFamily: 'Helvetica' },
heading: { fontSize: 24, marginBottom: 16 },
row: { flexDirection: 'row', borderBottom: '1px solid #eee', padding: '8px 0' },
cell: { flex: 1, fontSize: 11 },
})
export function InvoicePdf({ invoice }: { invoice: Invoice }) {
return (
<Document>
<Page size="A4" style={styles.page}>
<Text style={styles.heading}>Invoice #{invoice.number}</Text>
{invoice.lineItems.map(item => (
<View key={item.id} style={styles.row}>
<Text style={styles.cell}>{item.description}</Text>
<Text style={styles.cell}>{item.qty}</Text>
<Text style={styles.cell}>${item.total.toFixed(2)}</Text>
</View>
))}
</Page>
</Document>
)
}
// Server-side: render to buffer
import { renderToBuffer } from '@react-pdf/renderer'
const pdfBuffer = await renderToBuffer(<InvoicePdf invoice={invoice} />)
pdfkit (lightweight, no browser)
import PDFDocument from 'pdfkit'
import { Writable } from 'stream'
function generateSimplePdf(title: string, lines: string[]): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: 50 })
const chunks: Buffer[] = []
doc.pipe(new Writable({
write(chunk, _, cb) { chunks.push(chunk); cb() },
final(cb) { resolve(Buffer.concat(chunks)); cb() },
}))
doc.fontSize(24).text(title, { align: 'center' })
doc.moveDown()
lines.forEach(line => doc.fontSize(12).text(line))
doc.end()
})
}
Serverless with @sparticuz/chromium
// For Vercel/Lambda where system Chrome is not available
import chromium from '@sparticuz/chromium'
import puppeteer from 'puppeteer-core'
const browser = await puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath(),
headless: chromium.headless,
})
How to pick
| Use case |
Recommended tool |
| Invoice / receipt template |
@react-pdf/renderer |
| Report with charts |
Playwright (render charts in browser) |
| Simple text document |
pdfkit |
| Existing HTML page to PDF |
Playwright |
| Serverless / edge |
pdfkit or @react-pdf/renderer |
| Python backend |
reportlab or weasyprint |
Common mistakes
Not waiting for fonts/images to load — waitUntil: 'networkidle' in Playwright ensures external resources finish loading before capture. Without it, images appear blank.
Hardcoding page breaks — use CSS page-break-after: always or break-after: page for logical breaks; don't try to calculate when content overflows manually.
Opening a new browser per request — keep the Playwright/Puppeteer browser instance alive across requests or use a pool; cold-starting Chrome on every PDF request adds ~2 seconds of latency.
Missing printBackground: true — without it, background colours and images are stripped from the PDF output.
What to skip
wkhtmltopdf — abandoned upstream, poor CSS support, fails on modern web features.
html-pdf (npm) — wraps wkhtmltopdf; same problems.
- Generating PDFs on the client — it works in theory but file size, font embedding, and page layout are unreliable in pure browser JS. Generate server-side and serve the result.
FAQ
Can I generate PDFs in a Next.js API route?
Yes. Use @react-pdf/renderer (no Chrome required) or pdfkit for serverless compatibility. For Playwright, deploy a separate service or use @sparticuz/chromium on Lambda/Vercel.
How do I add custom fonts?
Playwright: embed via CSS @font-face in your HTML. @react-pdf/renderer: use Font.register({ family: 'MyFont', src: url }). pdfkit: doc.registerFont('MyFont', path).
What about password-protecting the generated PDF?
pdfkit supports doc = new PDFDocument({ userPassword: 'secret', ownerPassword: 'admin' }). Playwright-generated PDFs need post-processing with qpdf or gs (Ghostscript).
How large do PDF files get?
A plain text invoice is typically 20–80 KB. Add images or custom fonts and it grows to 200 KB–2 MB. Compress images before embedding and subset fonts to keep files small.
Where to go next