Forme: PDF generation with JSX
An open source Rust PDF engine with a React component layer. Page breaks that actually work. 28ms renders. No headless browser.
I built a soil analysis platform. Farmers send in samples, I process the results, and generate PDF reports. Cover page, nutrient tables, product recommendations, 4 pages per report.
The PDF pipeline was the worst part of the entire codebase. 700 lines of HTML string concatenation fed into a Puppeteer server running headless Chrome. It was slow, fragile, and the page breaks were wrong about 30% of the time. A table row would get clipped at the page boundary. A recommendations section would start at the bottom of a page with two lines visible and the rest on the next page.
I tried every library. react-pdf doesn't do page breaks across flowing content. jsPDF requires manual x/y positioning. pdfmake uses a custom JSON syntax that's unreadable at scale. PDFKit is low-level enough that you're basically writing PostScript. Every option either couldn't handle pagination or required giving up the component model I wanted.
So I built the engine I needed.
What Forme is
A Rust layout engine that compiles to WASM, paired with a React component layer. You write JSX, it renders PDFs.
import { Document, Page, View, Text } from '@formepdf/react';
import { renderDocument } from '@formepdf/core';
const doc = (
<Document>
<Page size="Letter" margin={54}>
<Text style={{ fontSize: 24, fontWeight: 700 }}>Hello World</Text>
<Text style={{ fontSize: 14, color: '#666', marginTop: 12 }}>
This is a PDF generated from JSX.
</Text>
</Page>
</Document>
);
const pdfBytes = await renderDocument(doc);
renderDocument returns a Uint8Array. Those are the bytes of a valid PDF file. Write them to disk, upload to S3, attach to an email, return as an HTTP response. The engine doesn't care what you do with the bytes.
Page breaks
This is the thing that pushed me to build Forme. Page breaks in Puppeteer are a CSS property (break-before, break-inside) that works unreliably in Chrome's print pipeline. In Forme, page breaks are a core concept in the layout engine.
Content flows across pages automatically. If a section doesn't fit on the current page, it continues on the next page. Headers and footers repeat on every page. wrap={false} on a component means the engine will move the entire component to the next page if it doesn't fit in the remaining space.
<Page size="Letter" margin={{ top: 54, right: 54, bottom: 72, left: 54 }}>
<Fixed position="header">
<Header />
</Fixed>
<Fixed position="footer">
<Text style={{ textAlign: 'center', fontSize: 10, color: '#999' }}>
Page {'{{pageNumber}}'} of {'{{totalPages}}'}
</Text>
</Fixed>
{data.sections.map(section => (
<View wrap={false} style={{ marginBottom: 16 }}>
<Text style={{ fontSize: 16, fontWeight: 700 }}>{section.title}</Text>
<Text>{section.body}</Text>
</View>
))}
</Page>
Each section stays together. If there's not enough room, it starts on the next page. The <Fixed> elements repeat the header and footer on every page. This isn't clever CSS. It's a layout engine that understands what a page is.
Speed
A 4-page report renders in about 28ms. That's the Rust engine parsing the document, calculating flex layout, flowing content across pages, subsetting fonts, and writing the PDF binary format. There's no browser startup, no DOM construction, no paint step.
For comparison, the same document in Puppeteer takes 1-5 seconds depending on Chrome's state. Cold starts are worse.
Components
Templates are React components. You get composition, props, conditional rendering, loops. The same patterns you use for UI, applied to documents.
function InvoiceLineItem({ item }) {
return (
<View style={{ flexDirection: 'row', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#eee' }}>
<Text style={{ flex: 3 }}>{item.description}</Text>
<Text style={{ flex: 1, textAlign: 'right' }}>{item.quantity}</Text>
<Text style={{ flex: 1, textAlign: 'right' }}>{item.price}</Text>
</View>
);
}
function Invoice({ data }) {
return (
<Document>
<Page size="Letter" margin={54}>
<Text style={{ fontSize: 24 }}>Invoice #{data.number}</Text>
{data.items.map(item => <InvoiceLineItem item={item} />)}
<View style={{ marginTop: 16, alignItems: 'flex-end' }}>
<Text style={{ fontSize: 18, fontWeight: 700 }}>Total: {data.total}</Text>
</View>
</Page>
</Document>
);
}
No HTML string concatenation. No custom DSL. Just components.
The engine
The layout engine is written in Rust. It handles:
- Flex layout (the same model as CSS flexbox, applied to paged documents)
- Content flow across pages with automatic breaks
- Font subsetting (only the glyphs you use are embedded)
- PDF binary generation (cross-reference tables, stream objects, the whole spec)
It compiles to WASM and runs anywhere JavaScript runs: Node.js, Bun, Deno, Cloudflare Workers, even the browser. The bundle is about 3MB.
Where it runs
Because the engine is WASM, it works everywhere:
- Node.js --
renderDocument()in a server route - Vercel Functions -- fits in the 50MB bundle limit (3MB vs Chromium's 200MB)
- Cloudflare Workers -- runs at the edge, something Puppeteer literally cannot do
- Bun and Deno -- WASM is a first-class citizen
- The browser -- client-side PDF generation without a server round trip
The ecosystem
Forme isn't just an engine. It's a set of packages that put PDFs where they need to go:
@formepdf/core-- the Rust WASM engine@formepdf/react-- JSX components (Document, Page, View, Text, Image)@formepdf/cli-- dev server with hot reload, build tools

@formepdf/mcp-- MCP server for AI tools (Claude, Cursor, Windsurf)@formepdf/resend-- render a PDF and email it in one call@formepdf/next-- Next.js App Router route handlers and server actions@formepdf/hono-- Hono middleware for edge/Workers deployment
Every package is a thin wrapper over the core engine. The engine makes the bytes. The packages deliver them.
Open source
Forme is MIT licensed. The engine, the components, the CLI, all of it.
npm install @formepdf/react @formepdf/core
If you're running headless Chrome just to make PDFs, you might not need to.