Forme 0.12.0 — a Preact adapter, no compat shim required
@formepdf/preact ships in 0.12.0. Full component parity with @formepdf/react, authored with Preact's JSX runtime — no preact/compat in your bundle, no unmet React peer, no cross-runtime interop worries.
Forme 0.12.0 adds a Preact 10 authoring adapter. If you're on Preact for the bundle size — which is most of why anyone is on Preact — you now don't have to reach for preact/compat to use Forme.
npm install @formepdf/preact @formepdf/core
The full component set is exported: Document, Page, View, Text, semantic headings (H1–H6), lists, inline formatting (Strong, Em, Code, Link), tables, media (Image, Svg, QrCode, Barcode, Canvas, Watermark), all five chart types, form fields, and layout primitives (PageBreak, Fixed). Same props as @formepdf/react, byte-identical serialized JSON.
Same components, different runtime
/** @jsxImportSource preact */
import { Document, Page, View, Text, renderDocument } from '@formepdf/preact';
const pdf = await renderDocument(
<Document>
<Page size="Letter" margin={36}>
<Text style={{ fontSize: 24, fontWeight: 'bold' }}>Invoice #001</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text>Widget Pro</Text>
<Text>$49.00</Text>
</View>
</Page>
</Document>
);
Or set jsxImportSource: "preact" in your tsconfig.json and skip the /** @jsxImportSource */ pragma. Every code example in the components reference works verbatim — swap the import path and go.
Why a native adapter (vs preact/compat)
You can already use @formepdf/react with Preact today via preact/compat aliasing. Both paths produce identical PDFs. But:
- Bundle size —
preact/compatadds ~7-8KB gzipped to your output. If you're on Preact to escape React's runtime, adding a compat shim back defeats a lot of that. - No unmet-peer warning —
@formepdf/react's peer isreact ^18 || ^19. npm warns when it's not installed.@formepdf/preact's peer ispreact ^10.19.0. - Preact-native JSX runtime — no cross-runtime interop concerns, no
@types/reactin yourdevDependencies.
How it works
Under the hood, @formepdf/react doesn't do much React. Every component (Document, Page, View, etc.) is a plain (props): null => null function — no hooks, no state, no rendering. All the work happens in serialize(), which walks the JSX tree and does reference-equality checks (child.type === Page) to identify Forme primitives, then emits a document-model JSON.
That means porting to Preact was mechanical: swap import { isValidElement, Children, Fragment } from 'react' for the Preact equivalents (isValidElement, toChildArray, Fragment), thread VNode where ReactElement used to be, and you're done. About 30 lines of real diff across the whole serializer.
The framework-neutral bits (style mapping, the Font store, CSS shorthand parsing, chart kind builders, semantic-component defaults) were already extracted into @formepdf/shared when we added Svelte in 0.11.0. Both adapters — and any future one — depend on it.
Parity is enforced
Cross-adapter parity isn't a claim; it's a test. The @formepdf/preact test suite includes fixture pairs (.preact.tsx + .react.tsx) that author the same document twice — once with each adapter — and assert byte-identical serialized JSON. If a change breaks parity, CI catches it before it ships. The same pattern secures Svelte parity.
Compiled templates
The template expression system ($ref, $each, $if, expression helpers) works identically with the Preact adapter. The recording-proxy layer is framework-agnostic. If you're using Forme's hosted API with dynamic data from JSON, the workflow doesn't change.
Credit
Preact support was requested via a GitHub issue — thanks to whoever prompted it. The right kind of ask.
Get started
- Preact adapter guide
- Component reference (identical across all three adapters)
- GitHub — star, file issues, send PRs
- Playground — try it in the browser