How to Generate PDFs in JavaScript
The modern options for generating PDFs in JavaScript — from Puppeteer to PDFKit to JSX components. Code examples for Node, Next.js, Express, and Cloudflare Workers.
Generating PDFs in JavaScript has historically meant one of two things: spinning up a headless Chrome instance with Puppeteer, or wrestling with low-level PDF libraries that require you to position every element manually.
Neither is great. This guide covers the modern options and when to use each.
The Options
1. Puppeteer (headless Chrome)
The most common approach. You write HTML, Puppeteer renders it in Chrome, and exports a PDF.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent('<h1>Hello</h1>');
const pdf = await page.pdf({ format: 'A4' });
await browser.close();
The problems:
- Chrome is 200-300MB. Deploying it is painful.
- Cold starts on Lambda: 8-12 seconds.
- CSS page breaks are inconsistent and fragile.
- Can't run on Cloudflare Workers or Vercel Edge.
2. PDFKit
A low-level JavaScript PDF library. You describe the document programmatically -- position text, draw rectangles, embed fonts.
import PDFDocument from 'pdfkit';
const doc = new PDFDocument();
doc.fontSize(24).text('Hello World', 100, 100);
doc.end();
Fine for simple documents. Painful for anything with dynamic content, tables, or page breaks.
3. Forme
A PDF engine written in Rust, compiled to WASM. You write JSX components -- the same mental model as React -- and Forme renders them to PDF bytes in milliseconds. No Chrome, no manual positioning.
import { renderDocument } from '@formepdf/core';
import { Document, Page, Text, View } from '@formepdf/react';
const pdf = await renderDocument(
<Document>
<Page size="A4" margin={54}>
<Text style={{ fontSize: 24, fontWeight: 700 }}>
Hello World
</Text>
</Page>
</Document>
);
Generating PDFs with Forme
Install
npm install @formepdf/core @formepdf/react
Basic document
import { renderDocument } from '@formepdf/core';
import { Document, Page, Text, View } from '@formepdf/react';
const pdf = await renderDocument(
<Document>
<Page size="Letter" margin={54}>
<Text style={{ fontSize: 24, fontWeight: 700, color: '#1e293b' }}>
Invoice #2024-001
</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 24 }}>
<View>
<Text style={{ fontSize: 10, color: '#64748b' }}>Bill To</Text>
<Text style={{ fontSize: 12, fontWeight: 700, marginTop: 4 }}>
Acme Corp
</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={{ fontSize: 10, color: '#64748b' }}>Due Date</Text>
<Text style={{ fontSize: 12, fontWeight: 700, marginTop: 4 }}>
March 1, 2026
</Text>
</View>
</View>
</Page>
</Document>
);
// pdf is a Uint8Array
// Save to file, return from an API, attach to an email
With dynamic data
interface InvoiceData {
customer: string;
items: { name: string; price: number }[];
}
function Invoice({ customer, items }: InvoiceData) {
const total = items.reduce((sum, item) => sum + item.price, 0);
return (
<Document>
<Page size="Letter" margin={54}>
<Text style={{ fontSize: 24, fontWeight: 700 }}>
Invoice
</Text>
<Text style={{ fontSize: 12, marginTop: 8, color: '#64748b' }}>
{customer}
</Text>
<Table
columns={[
{ width: { fraction: 0.6 } },
{ width: { fraction: 0.4 } },
]}
style={{ marginTop: 24 }}
>
<Row header style={{ backgroundColor: '#1e293b' }}>
<Cell style={{ padding: 8 }}>
<Text style={{ color: '#fff', fontWeight: 700 }}>Item</Text>
</Cell>
<Cell style={{ padding: 8 }}>
<Text style={{ color: '#fff', fontWeight: 700 }}>Price</Text>
</Cell>
</Row>
{items.map((item, i) => (
<Row key={i} style={{ backgroundColor: i % 2 === 0 ? '#fff' : '#f8fafc' }}>
<Cell style={{ padding: 8 }}>
<Text>{item.name}</Text>
</Cell>
<Cell style={{ padding: 8 }}>
<Text>${item.price.toFixed(2)}</Text>
</Cell>
</Row>
))}
</Table>
<View style={{ flexDirection: 'row', justifyContent: 'flex-end', marginTop: 16 }}>
<Text style={{ fontSize: 14, fontWeight: 700 }}>
Total: ${total.toFixed(2)}
</Text>
</View>
</Page>
</Document>
);
}
const pdf = await renderDocument(
<Invoice
customer="Acme Corp"
items={[
{ name: 'Website Redesign', price: 3500 },
{ name: 'Hosting (12 months)', price: 600 },
]}
/>
);
Save to file
import { writeFileSync } from 'fs';
writeFileSync('invoice.pdf', pdf);
Return from an Express API
import express from 'express';
import { renderDocument } from '@formepdf/core';
import { Document, Page, Text } from '@formepdf/react';
const app = express();
app.post('/generate-pdf', async (req, res) => {
const pdf = await renderDocument(
<Document>
<Page size="Letter" margin={54}>
<Text style={{ fontSize: 24 }}>{req.body.title}</Text>
</Page>
</Document>
);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="document.pdf"');
res.send(Buffer.from(pdf));
});
Return from a Next.js route handler
import { NextRequest, NextResponse } from 'next/server';
import { renderDocument } from '@formepdf/core';
import { Document, Page, Text } from '@formepdf/react';
export async function POST(req: NextRequest) {
const { title } = await req.json();
const pdf = await renderDocument(
<Document>
<Page size="Letter" margin={54}>
<Text style={{ fontSize: 24 }}>{title}</Text>
</Page>
</Document>
);
return new NextResponse(pdf, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="document.pdf"',
},
});
}
Page Breaks
This is where most JavaScript PDF libraries fall apart. Puppeteer uses CSS page breaks which are fragile. PDFKit requires manual page management.
Forme has a layout engine built for pages. Content flows into pages correctly by default:
<Table columns={[
{ width: { fraction: 0.5 } },
{ width: { fraction: 0.5 } },
]}>
<Row header style={{ backgroundColor: '#1e293b' }}>
<Cell style={{ padding: 8 }}>
<Text style={{ color: '#fff', fontWeight: 700 }}>Item</Text>
</Cell>
<Cell style={{ padding: 8 }}>
<Text style={{ color: '#fff', fontWeight: 700 }}>Price</Text>
</Cell>
</Row>
{items.map((item, i) => (
<Row key={i}>
<Cell style={{ padding: 8 }}>
<Text>{item.name}</Text>
</Cell>
<Cell style={{ padding: 8 }}>
<Text>{item.price}</Text>
</Cell>
</Row>
))}
</Table>
Table headers repeat on every page automatically. Rows never split across page boundaries. No CSS hacks required.
Running on Cloudflare Workers
Forme compiles to WASM and runs anywhere JavaScript runs -- including Cloudflare Workers, where Puppeteer cannot run at all.
export default {
async fetch(request) {
const { renderDocument } = await import('@formepdf/core');
const { Document, Page, Text } = await import('@formepdf/react');
const pdf = await renderDocument(
<Document>
<Page size="Letter" margin={54}>
<Text>Generated on the edge</Text>
</Page>
</Document>
);
return new Response(pdf, {
headers: { 'Content-Type': 'application/pdf' },
});
},
};
No Chrome, no cold starts, no native dependencies.
Using the Hosted API
If you don't want to manage the WASM binary, the Forme hosted API handles rendering for you:
const response = await fetch(
'https://api.formepdf.com/v1/render/invoice',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.FORME_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
customer: 'Acme Corp',
amount: 4100,
}),
}
);
const pdf = await response.arrayBuffer();
Build templates in the dashboard, call them from any JavaScript environment via API. Under 500ms end-to-end.
PDF Generation vs PDF Operations
Once you can generate PDFs programmatically, the next requirement is usually operating on them -- redacting sensitive information, merging multiple documents, certifying with a digital signature.
Forme handles all of these as API endpoints:
# Redact sensitive data
POST /v1/redact
# Merge multiple PDFs
POST /v1/merge
# Certify with a digital certificate
POST /v1/certify
The same API that generates your PDFs can also process them.
Getting Started
npm install @formepdf/core @formepdf/react
Or use the hosted API -- sign up at app.formepdf.com, get an API key, and start generating PDFs with a single HTTP call. The free plan includes 50 operations per month.
The VS Code extension gives you live PDF preview as you write JSX -- install it from the marketplace.