Generating PDFs from a web application should not require a server round-trip, a headless browser, or a fragile chain of JavaScript libraries. Yet for most teams, that is exactly what it requires. The typical approach β spin up Puppeteer, render HTML to a virtual page, print to PDF β is slow, resource-hungry, and surprisingly difficult to get right when pixel-perfect layout matters.
What if you could compile a document directly in the browser, with precise typographic control, custom fonts, and structured data injection, all in under a second?
pdf-generator
is an open-source Rust library that does exactly that. It compiles to
WebAssembly via
wasm-pack, wraps the
Typst
document compiler, and exposes a single function β
render_pdf
β that takes a template and font data and returns raw PDF bytes. It
works in both the browser and Node.js, making it a genuinely
cross-platform solution for programmatic document generation.
In this article, we will walk through how it works, why the technology choices are compelling, and how you can start using it in your own projects.
What Is Typst and Why Should You Care?
Typst is a modern typesetting system designed as a practical alternative to LaTeX. Where LaTeX carries decades of macro complexity and cryptic error messages, Typst offers a clean scripting language with first-class support for variables, loops, conditionals, and function definitions β all while producing publication-quality output.
Here is a minimal Typst template:
#set page(paper: "a4") #set text(font: "Inter") = Invoice Due date: *2026-03-01* #table( columns: 4, table.header([Qty], [Description], [Unit Price], [Total]), [1], [Consulting Service], [150], [150], [2], [Software Development], [800], [1600], )
That template produces a properly typeset A4 document with a heading, bold text, and a formatted table. No XML. No verbose markup. No fighting with CSS print stylesheets.
For programmatic document generation, Typst has a critical advantage:
it is implemented as a Rust library. That means it can be compiled to
WebAssembly and executed directly in the browser, without any server
infrastructure. This is the foundation that
pdf-generator
builds on.
Architecture: Rust to WASM to Your Browser
The compilation pipeline is straightforward, but each layer is doing important work.
Rust source code
is compiled to a
WebAssembly module
using
wasm-pack, which orchestrates the build process and generates JavaScript
bindings via
wasm-bindgen. The resulting
.wasm
file, along with a thin JS wrapper, can be loaded by any modern
browser or Node.js runtime.
The project's
Cargo.toml
reveals the key dependencies:
[lib] crate-type = ["cdylib"] [dependencies] typst = { version = "0.13.1", default-features = false } typst-pdf = { version = "0.13.1", default-features = false } typst-as-lib = { version = "0.14.4", default-features = false } wasm-bindgen = { version = "0.2", default-features = false } serde = { version = "1.0.219", features = ["derive"] } serde-wasm-bindgen = { version = "0.6.5", default-features = false }
A few things to note. The crate type is
cdylib, which tells the compiler to produce a C-compatible dynamic library
β the format
wasm-pack
needs to generate a
.wasm
binary. The
default-features = false
flags are deliberate: they strip out functionality that either does
not compile to WASM or is unnecessary, keeping the output size
manageable. The release profile uses
opt-level = "s"
and
lto = true
to aggressively optimize for binary size.
The typst-as-lib crate deserves special mention. It wraps Typst's compiler into a convenient library interface with a builder pattern, handling the complexity of font registration, source file management, and compilation orchestration. Without it, setting up the Typst compiler programmatically would require significantly more boilerplate.
The Core API:
render_pdf
The entire public API surface is essentially one function. Here is the Rust source, lightly annotated:
#[derive(Default, Deserialize)] struct RenderOptions { template: String, font: Vec<u8>, } #[wasm_bindgen] pub fn render_pdf( render_options: JsValue, data: JsValue, ) -> Result<Vec<u8>, JsValue> { // Deserialize the JS object into a Rust struct let options = RenderOptions::try_from(render_options)?; // Build the Typst engine with the template and font let template = TypstEngine::builder() .main_file(options.template.as_str()) .fonts([options.font]) .build(); // Convert JSON data from JS into Typst's native Dict type let json_value = from_value(data).map_err(log_error)?; let input_data = json_to_typst(json_value); let dict = Dict::from_value(input_data).unwrap(); // Compile the document and export to PDF let doc = template .compile_with_input(dict) .output .map_err(log_error)?; let pdf = typst_pdf::pdf(&doc, &PdfOptions::default()) .expect("Error exporting PDF"); Ok(pdf) }
The function accepts two
JsValue
arguments β opaque JavaScript values that cross the WASM boundary. The
first is deserialized into
RenderOptions
(a template string and font bytes). The second is arbitrary JSON data
that gets converted into Typst's native
Dict
type through a recursive
json_to_typst
converter. This is what makes the library genuinely useful for dynamic
documents: you can pass structured data from your JavaScript
application and reference it inside your Typst template using
#import sys: inputs.
The return type,
Result<Vec<u8>, JsValue>, becomes a JavaScript
Uint8Array
on success or throws on failure. Clean, predictable, and easy to work
with on both sides of the boundary.
Using It in the Browser
Build the WASM module for the web target:
wasm-pack build --target web
Then load and call it from JavaScript:
<!-- Required: Rust expects valid UTF-8 strings --> <meta charset="UTF-8"> <script type="module"> import init, { render_pdf } from './pkg/pdf_generator.js'; await init(); // Load a font file (e.g., from a file input or fetch) const fontResponse = await fetch('/fonts/Inter-Regular.ttf'); const fontBytes = Array.from( new Uint8Array(await fontResponse.arrayBuffer()) ); const render_options = { template: ` #import sys: inputs #set page(paper: "a4") = Invoice \##{inputs.invoice_number} *Client:* #{inputs.client_name} #table( columns: 4, table.header([Qty], [Item], [Price], [Total]), ..inputs.items.map(it => ( [#it.at(0)], [#it.at(1)], [#it.at(2)], [#(it.at(0) * it.at(2))] )).flatten() ) `, font: fontBytes, }; const data = { invoice_number: "2026-0042", client_name: "Acme Corp", items: [ [2, "API Integration", 950], [5, "Support Hours", 120], ], }; const pdfBytes = render_pdf(render_options, data); const blob = new Blob([pdfBytes], { type: 'application/pdf' }); window.open(URL.createObjectURL(blob)); </script>
That is the entire integration. No server calls. No headless browser. The PDF is compiled in the user's browser tab and can be displayed immediately or downloaded.
One important detail: the HTML page must declare
charset="UTF-8". Rust strings are guaranteed to be valid UTF-8, and if the browser sends strings in a different encoding across the WASM boundary, deserialization will fail with opaque errors.
Using It in Node.js
The same library compiles to a Node.js module with a single flag change:
wasm-pack build --target nodejs
The API is identical:
const fs = require('node:fs'); const { render_pdf } = require('./pkg/pdf_generator'); const font = fs.readFileSync('./fonts/Inter-Regular.ttf'); const render_options = { template: ` #set page(paper: "a4") = Monthly Report Generated on #datetime.today().display() `, font: Array.from(font), }; const pdfBytes = render_pdf(render_options, null); fs.writeFileSync('report.pdf', pdfBytes);
This is particularly valuable for backend services that need to generate documents without installing system-level dependencies like wkhtmltopdf, LibreOffice, or a headless Chrome instance. The WASM module is self-contained: it needs only the font file you provide.
Why Rust Makes This Work
It is worth pausing to appreciate why Rust is uniquely suited for this kind of project.
WebAssembly target maturity
Rust's WASM toolchain β
wasm-pack,
wasm-bindgen, and the
wasm32-unknown-unknown
target β is arguably the most mature in any systems language. The
#[wasm_bindgen]
attribute handles the serialization boundary between JavaScript
and Rust automatically. Types like
Vec<u8>
become
Uint8Array.
Result<T, E>
becomes a value-or-throw pattern.
Memory safety without garbage collection
The Typst compiler processes complex document trees, resolves fonts, performs layout calculations, and serializes PDF structures. In a garbage-collected language, this workload would introduce unpredictable pauses. In C or C++, it would be a minefield of buffer overflows. Rust gives you deterministic performance with compile-time safety guarantees β exactly what you want when running a compiler inside another runtime.
Performance with small binaries
The release profile uses
opt-level = "s"
combined with link-time optimization. This means the WASM binary
is as small as the compiler can make it while still running at
near-native speed. For a library that gets downloaded to the
browser on every page load, binary size matters.
The ecosystem effect
This project exists because Typst is written in Rust,
typst-as-lib
wraps it for library use, and
wasm-pack
makes cross-compilation trivial. None of these pieces were built
specifically for
pdf-generator, but because they all share the same language and toolchain,
they compose cleanly. This is the Rust ecosystem working as
intended.
Use Cases
The most obvious applications involve any document that combines a fixed layout with dynamic data:
- Invoices and receipts β Generate client-specific billing documents directly in the browser after a checkout flow, with no round-trip to the server.
- Reports and dashboards β Compile data summaries into formatted PDFs for download. Useful in analytics tools, admin panels, or CRM systems.
- Certificates and credentials β Produce personalized certificates (course completion, event attendance) on the fly.
- Contracts and forms β Pre-fill legal or administrative documents with user-supplied data.
- Offline-capable applications β Since PDF generation runs entirely in the browser, it works without network access once the WASM module and fonts are cached.
Limitations and Considerations
A few things to keep in mind before adopting this approach:
-
WASM binary size.
The Typst compiler is not trivial. Even with size optimizations, the
.wasmfile will be larger than a typical JavaScript bundle. Lazy loading and caching strategies (service workers, HTTP caching headers) can mitigate this. - Font management. Fonts must be passed as raw byte arrays. You need to fetch or bundle the fonts yourself. There is no system font access from within WASM.
- Single font per call. The current API accepts one font in the render options. Documents requiring multiple font families may need API extensions.
-
Error messages.
Typst compilation errors are surfaced through Rust's error chain, but
messages crossing the WASM boundary can lose context. The
console_error_panic_hookcrate helps, but debugging template issues is easier with the standalone Typst CLI during development.
Getting Started
Clone the repository and build:
git clone https://github.com/markonyango/pdf-generator.git cd pdf-generator # For browser use wasm-pack build --target web --no-opt --dev # Start a local server and open index.html python3 -m http.server
The included
index.html
provides a working demo: paste a Typst template, upload a font file,
and generate a PDF in your browser. It even includes an example invoice
template that demonstrates data injection via
#import sys: inputs.
Final Thoughts
pdf-generator
sits at an interesting intersection of technologies: Rust's safety and
performance, WebAssembly's portability, and Typst's modern approach to
document compilation. The result is a library that makes client-side
PDF generation not just possible but practical.
If you have been fighting with jsPDF's limited layout capabilities, wrestling with Puppeteer's resource overhead, or maintaining a server-side PDF generation service that you wish did not exist, this project is worth your attention.
The codebase is compact (the core logic fits in a single
lib.rs), the API is minimal, and the MIT license means you can use it
however you like. Check out the project on
GitHub, open an issue if you run into problems, and consider contributing if
you see something you would improve.