DEV Community

Cover image for Generate PDF invoices in React with a live preview
Niklas Eicker
Niklas Eicker

Posted on

Generate PDF invoices in React with a live preview

If your React app needs to produce a PDF, there are two well-worn paths and they're both bad.

Path one: HTML-to-PDF on a server. Build the document as HTML, ship it to a service running headless Chromium, get a PDF back. You're now operating a 100 MB browser, fighting CSS for page breaks, and paying network latency for something that's conceptually local. You can swap Puppeteer for wkhtmltopdf or a SaaS, but the architecture stays the same.

Path two: jsPDF / pdfmake in the browser. No server, great. But the document is now JavaScript. doc.text(...), doc.rect(...), manual coordinates, no real layout engine. Fine for some simple documents, but painful for anything with e.g. a table that needs to flow across pages.

There's a third path: ship a real typesetter to the browser as WebAssembly, write the document in a proper layout language, and compile to PDF locally in milliseconds.

That's what we'll build. By the end of this post you'll have a React app where the user types into a form, watches the PDF update live as they type, then downloads the final file with one click. The whole pipeline with typesetter, layout engine, and PDF rendering runs in the browser tab on every keystroke. No backend. Nothing in the network tab.

A form with invoice items and other inputs is shown on the left and a live preview of the document on the right. The user changes some values, removes and adds items and the preview refreshes with every change.

The typesetter is Typst, and the bridge between Typst and your application code is Oicana, which I built. Oicana is source-available. Free for non-commercial use, paid for commercial use, full pricing at the end. The Typst compiler underneath is open source.

What you'll need

  • Node.js 18+
  • An editor with Typst support. Tinymist for VS Code gives you syntax highlighting and a live preview pane. Use that if you don't already have a Typst setup.
  • The Oicana CLI. On macOS, Linux, or WSL:
  curl --proto '=https' --tlsv1.2 -LsSf \
    https://github.com/oicana/oicana/releases/download/oicana_cli-v0.1.0/oicana_cli-installer.sh | sh
Enter fullscreen mode Exit fullscreen mode

On Windows (PowerShell):

  powershell -ExecutionPolicy Bypass -c "irm https://github.com/oicana/oicana/releases/download/oicana_cli-v0.1.0/oicana_cli-installer.ps1 | iex"
Enter fullscreen mode Exit fullscreen mode

Or grab a prebuilt binary from GitHub. Confirm with oicana --version.

Step 1: scaffold the React app

npm create vite@latest invoice-app -- --template react-ts -i
Enter fullscreen mode Exit fullscreen mode

This will scaffold the application, install dependencies with npm, and start the dev server at http://localhost:5173. If you open that URL in your browser you should see the default Vite + React starter.

Step 2: write the invoice template

Templates are plain Typst projects. Create one with the CLI:

oicana new invoice
cd invoice
Enter fullscreen mode Exit fullscreen mode

You get two files: main.typ (the document) and typst.toml (the manifest). We're going to edit both.

First, typst.toml. We need one JSON input called invoice that carries the whole payload. For development we'll point at a development.json so the editor preview shows real-looking data:

[package]
name = "invoice"
version = "0.1.0"
entrypoint = "main.typ"

[tool.oicana]
manifest_version = 1

[[tool.oicana.inputs]]
type = "json"
key = "invoice"
development = "development.json"
Enter fullscreen mode Exit fullscreen mode

Then create the json file with development data. The live preview uses this when you're editing in VS Code.

{
  "number": "2026-0042",
  "date": "2026-05-15",
  "from": {
    "name": "Jane Doe Web Studio",
    "address": "Marktplatz 5\n50667 Köln\nGermany"
  },
  "to": {
    "name": "Smith GmbH",
    "address": "Hauptstraße 12\n10115 Berlin\nGermany"
  },
  "items": [
    { "description": "Homepage redesign", "qty": 1, "price": 4800 },
    { "description": "Logo refresh", "qty": 1, "price": 1200 },
    { "description": "On-call support (hours)", "qty": 6, "price": 110 }
  ],
  "taxRate": 0.19
}
Enter fullscreen mode Exit fullscreen mode

Now main.typ. The first three lines are the boilerplate you add to any template that consumes inputs: they import the helper package and pull the values out of the manifest. The rest is the invoice itself.

#import "@preview/oicana:0.1.1": setup

#let read-project-file(path) = read(path, encoding: none)
#let (input, oicana-image, oicana-config) = setup(read-project-file)

#let invoice = input.invoice

#set document(title: "Invoice " + invoice.number, date: datetime.today())
#set page(margin: 2cm)
#set text(size: 10pt)

#let euros(n) = [#n €]

// Header
#grid(
  columns: (1fr, 1fr),
  align: (left, right),
  [
    *#invoice.from.name* \
    #invoice.from.address.split("\n").join(linebreak())
  ],
  [
    #text(size: 22pt, weight: "bold")[Invoice] \
    *#invoice.number* \
    #invoice.date
  ],
)

#v(1.5em)

*Bill to:* \
#invoice.to.name \
#invoice.to.address.split("\n").join(linebreak())

#v(1em)

#table(
  columns: (1fr, auto, auto, auto),
  align: (left, right, right, right),
  inset: 8pt,
  table.header(
    [*Description*], [*Qty*], [*Unit price*], [*Line total*],
  ),
  ..for item in invoice.items {(
    [#item.description],
    [#item.qty],
    euros(item.price),
    euros(item.qty * item.price),
  )}
)

#let subtotal = invoice.items.fold(0, (acc, i) => acc + i.qty * i.price)
#let tax = calc.round(subtotal * invoice.taxRate, digits: 2)
#let total = subtotal + tax

#v(1em)
#align(right)[
  Subtotal: #euros(subtotal) \
  Tax (#calc.round(invoice.taxRate * 100)%): #euros(tax) \
  *Total: #euros(total)*
]
Enter fullscreen mode Exit fullscreen mode

If you are new to Typst and interested to learn more, please follow their tutorial. I am not going to go through the content of main.typ in this post. Typst's documentation is good and there are many resources to learn it out there already.

Open main.typ in VS Code with the Tinymist preview (Ctrl+K V). The rendered invoice updates as you edit, using development.json as the input. That's the loop you'll spend most of your template-writing time in.

The Oicana helper package wires input.invoice from the manifest, so the JSON keys become Typst dictionary fields (invoice.from.name, invoice.items, …). The example Oicana templates repo has more documents you can look at for reference.

Step 3: pack the template

From the invoice/ directory:

oicana validate
oicana pack
Enter fullscreen mode Exit fullscreen mode

validate checks the manifest. pack produces invoice-0.1.0.zip, a self-contained bundle that any Oicana integration can compile.

Step 4: wire it up in React

Back in invoice-app/. Install Oicana alongside Mantine for the UI:

npm install @oicana/browser @oicana/browser-wasm @mantine/core @mantine/hooks
Enter fullscreen mode Exit fullscreen mode

Copy invoice/invoice-0.1.0.zip into invoice-app/public/. The browser will fetch it from there.

Wire up Mantine in src/main.tsx:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { MantineProvider } from '@mantine/core';
import '@mantine/core/styles.css';
import App from './App.tsx';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <MantineProvider>
      <App />
    </MantineProvider>
  </StrictMode>,
);
Enter fullscreen mode Exit fullscreen mode

Then create src/useTemplate.ts, a small hook that loads the packed template once and gives you a compile() function for any output format. We'll reuse this throughout the rest of the post:

import { useCallback, useEffect, useState } from 'react';
import { CompilationMode, initialize, Template } from '@oicana/browser';
import wasmUrl from '@oicana/browser-wasm/oicana_browser_wasm_bg.wasm?url';

export type CompileTarget =
  | { format: 'pdf' }
  | { format: 'png'; pixelsPerPt?: number };

const cache = new Map<string, Promise<Template>>();
function load(zipUrl: string): Promise<Template> {
  if (!cache.has(zipUrl)) {
    cache.set(
      zipUrl,
      (async () => {
        await initialize(wasmUrl);
        const response = await fetch(zipUrl);
        const bytes = new Uint8Array(await response.arrayBuffer());
        return new Template(bytes);
      })(),
    );
  }
  return cache.get(zipUrl)!;
}

export function useTemplate(zipUrl: string) {
  const [template, setTemplate] = useState<Template | undefined>();

  useEffect(() => {
    load(zipUrl).then(setTemplate).catch(console.error);
  }, [zipUrl]);

  const compile = useCallback(
    (
      inputs: Record<string, unknown>,
      target: CompileTarget = { format: 'pdf' },
    ): Uint8Array<ArrayBuffer> | undefined => {
      if (!template) return undefined;
      const jsonInputs = new Map(
        Object.entries(inputs).map(([k, v]) => [k, JSON.stringify(v)] as const),
      );
      const exportFormat =
        target.format === 'png'
          ? { format: 'png' as const, pixelsPerPt: target.pixelsPerPt ?? 1 }
          : target;
      const mode =
        target.format === 'png'
          ? CompilationMode.Development
          : CompilationMode.Production;
      return template.compile(jsonInputs, new Map(), exportFormat, mode);
    },
    [template],
  );

  return { template, compile };
}
Enter fullscreen mode Exit fullscreen mode

Then replace src/App.tsx:

import { Button, Container } from '@mantine/core';
import { useTemplate } from './useTemplate.ts';

const TEMPLATE_URL = '/invoice-0.1.0.zip';

const SAMPLE_INVOICE = {
  number: '2026-0042',
  date: '2026-05-15',
  from: {
    name: 'Jane Doe Web Studio',
    address: 'Marktplatz 5\n50667 Köln\nGermany',
  },
  to: {
    name: 'Smith GmbH',
    address: 'Hauptstraße 12\n10115 Berlin\nGermany',
  },
  items: [
    { description: 'Homepage redesign', qty: 1, price: 4800 },
    { description: 'Logo refresh', qty: 1, price: 1200 },
    { description: 'On-call support (hours)', qty: 6, price: 110 },
  ],
  taxRate: 0.19,
};

function App() {
  const { template, compile } = useTemplate(TEMPLATE_URL);

  function download() {
    const pdf = compile({ invoice: SAMPLE_INVOICE });
    if (!pdf) return;
    const blob = new Blob([pdf], { type: 'application/pdf' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `invoice-${SAMPLE_INVOICE.number}.pdf`;
    a.click();
    URL.revokeObjectURL(url);
  }

  if (!template) return <Container py="lg">Preparing PDF engine…</Container>;

  return (
    <Container size="sm" py="lg">
      <Button onClick={download}>Download invoice</Button>
    </Container>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Run npm run dev, click the button, get an invoice-2026-0042.pdf. The template compiled to PDF inside the browser tab. The first page load fetches the WASM module (~36 MB uncompressed, ~11 MB brotli, then browser-cached).

Step 5: see what you're shipping

The same compile function the download uses can return PNG bytes instead of PDF. We can render those bytes into an <img> tag and get a live preview of the document for every input change. Refreshing the preview should only take a couple milliseconds.

Add src/Preview.tsx:

import { useEffect, useState } from 'react';
import { Image, Paper, Text } from '@mantine/core';
import { useTemplate } from './useTemplate.ts';

type PreviewProps = {
  zipUrl: string;
  invoice: unknown;
};

export function Preview({ zipUrl, invoice }: PreviewProps) {
  const { compile } = useTemplate(zipUrl);
  const [url, setUrl] = useState<string | null>(null);

  useEffect(() => {
    const png = compile({ invoice }, { format: 'png' });
    if (!png) return;
    let cancelled = false;
    const reader = new FileReader();
    reader.onload = () => {
      if (!cancelled) setUrl(reader.result as string);
    };
    reader.readAsDataURL(new Blob([png], { type: 'image/png' }));
    return () => {
      cancelled = true;
    };
  }, [compile, invoice]);

  return (
    <Paper withBorder p="sm">
      {url ? (
        <Image src={url} alt="Preview" />
      ) : (
        <Text c="dimmed">Compiling preview…</Text>
      )}
    </Paper>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now drop it into App.tsx next to the button. Wrap the render in a Grid so the button (and, in the next step, the form) sits next to the preview:

import { Button, Container, Grid } from '@mantine/core';
import { Preview } from './Preview.tsx';
import { useTemplate } from './useTemplate.ts';

// ...TEMPLATE_URL, SAMPLE_INVOICE, download() unchanged...

return (
  <Container size="xl" py="lg">
    <Grid gap="lg">
      <Grid.Col span={{ base: 12, md: 7 }}>
        <Button onClick={download}>Download invoice</Button>
      </Grid.Col>
      <Grid.Col span={{ base: 12, md: 5 }}>
        <Preview zipUrl={TEMPLATE_URL} invoice={SAMPLE_INVOICE} />
      </Grid.Col>
    </Grid>
  </Container>
);
Enter fullscreen mode Exit fullscreen mode

The preview re-compiles whenever invoice changes. Right now SAMPLE_INVOICE does not change, so it renders once and stays put. In the next step, we will make it interactive.

Step 6: drive it from a form

A real app doesn't hardcode the invoice. Drive src/App.tsx from a form and pass the live state straight to <Preview>. The Preview component doesn't change, just the source of its invoice prop:

import { useMemo, useState } from 'react';
import {
  Button,
  Container,
  Grid,
  Group,
  NumberInput,
  Stack,
  Table,
  TextInput,
} from '@mantine/core';
import { Preview } from './Preview.tsx';
import { useTemplate } from './useTemplate.ts';

const TEMPLATE_URL = '/invoice-0.1.0.zip';

type LineItem = {
  id: string;
  description: string;
  qty: number;
  price: number;
};

function App() {
  const { template, compile } = useTemplate(TEMPLATE_URL);
  const [customer, setCustomer] = useState('Smith GmbH');
  const [items, setItems] = useState<LineItem[]>([
    { id: crypto.randomUUID(), description: 'Homepage redesign', qty: 1, price: 4800 },
    { id: crypto.randomUUID(), description: 'Logo refresh', qty: 1, price: 1200 },
  ]);

  const invoice = useMemo(
    () => ({
      number: '2026-0042',
      date: new Date().toISOString().slice(0, 10),
      from: {
        name: 'Jane Doe Web Studio',
        address: 'Marktplatz 5\n50667 Köln\nGermany',
      },
      to: { name: customer, address: '' },
      items,
      taxRate: 0.19,
    }),
    [customer, items],
  );

  function updateItem(id: string, patch: Partial<LineItem>) {
    setItems((current) =>
      current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
    );
  }

  function addItem() {
    setItems((current) => [
      ...current,
      { id: crypto.randomUUID(), description: '', qty: 1, price: 0 },
    ]);
  }

  function removeItem(id: string) {
    setItems((current) => current.filter((item) => item.id !== id));
  }

  function download() {
    const pdf = compile({ invoice });
    if (!pdf) return;
    const blob = new Blob([pdf], { type: 'application/pdf' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `invoice-${invoice.number}.pdf`;
    a.click();
    URL.revokeObjectURL(url);
  }

  if (!template) return <Container py="lg">Preparing PDF engine…</Container>;

  return (
    <Container size="xl" py="lg">
      <Grid gap="lg">
        <Grid.Col span={{ base: 12, md: 7 }}>
          <Stack>
            <TextInput
              label="Bill to"
              value={customer}
              onChange={(e) => setCustomer(e.currentTarget.value)}
            />

            <Table withTableBorder verticalSpacing="xs">
              <Table.Thead>
                <Table.Tr>
                  <Table.Th>Description</Table.Th>
                  <Table.Th>Qty</Table.Th>
                  <Table.Th>Price (€)</Table.Th>
                  <Table.Th />
                </Table.Tr>
              </Table.Thead>
              <Table.Tbody>
                {items.map((item) => (
                  <Table.Tr key={item.id}>
                    <Table.Td>
                      <TextInput
                        value={item.description}
                        onChange={(e) => updateItem(item.id, { description: e.currentTarget.value })}
                      />
                    </Table.Td>
                    <Table.Td>
                      <NumberInput
                        value={item.qty}
                        min={0}
                        onChange={(value) => updateItem(item.id, { qty: Number(value) || 0 })}
                      />
                    </Table.Td>
                    <Table.Td>
                      <NumberInput
                        value={item.price}
                        min={0}
                        onChange={(value) => updateItem(item.id, { price: Number(value) || 0 })}
                      />
                    </Table.Td>
                    <Table.Td>
                      <Button
                        variant="subtle"
                        color="red"
                        size="xs"
                        onClick={() => removeItem(item.id)}
                      >
                        Delete
                      </Button>
                    </Table.Td>
                  </Table.Tr>
                ))}
              </Table.Tbody>
            </Table>

            <Group justify="space-between">
              <Button variant="default" onClick={addItem}>
                Add item
              </Button>
              <Button onClick={download}>Download invoice</Button>
            </Group>
          </Stack>
        </Grid.Col>

        <Grid.Col span={{ base: 12, md: 5 }}>
          <Preview zipUrl={TEMPLATE_URL} invoice={invoice} />
        </Grid.Col>
      </Grid>
    </Container>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Type in the form. The preview redraws. Type again. It redraws again. You can open DevTools' Network panel while you type: nothing.

Adding a new field, say a notes string, is a two-step change: drop it into the JSON payload in App.tsx and use it in the template.

Production notes

What's above works in development. There are a couple things we can improve, for example:

Move compilation to a Web Worker. template.compile(...) is synchronous and CPU-bound, so on the main thread it blocks rendering for the duration. Tens of milliseconds for a tiny invoice; visibly worse for a long report. Instantiate Template inside a Worker, post the inputs over, post the bytes back. The open source Oicana React example has a complete worker setup you can look at.

Pre-compress the WASM. Uncompressed it's ~36 MB, but with brotli it's ~11 MB. Add vite-plugin-compression so your build produces .wasm.br next to the .wasm, and serve the compressed file directly. The browser deployment guide covers MIME types, cache headers, and the import.meta.url form you need if you're on Webpack/Next.js instead of Vite.

To get our application production ready, we can also invest some time in more type safety. Oicana supports snapshot tests for templates and you can add json schemas to inputs for additional validation. The invoice here is minimal and will likely need more work for a real-world usage, but Typst is up for the task.

License & pricing

Oicana is source-available under the PolyForm Noncommercial License. You can find the code on GitHub. Personal projects, hobby use, research, education, and non-profits are free. Commercial use comes with 30 days of free testing, then €19/€49/€99 per month per project depending on company size, with a 30-day money-back guarantee. Full details on oicana.com.

What's next

  • The getting started guide walks through the same flow with more depth on the template side.
  • github.com/oicana/oicana-example-typescript-react is a full open source app with the Web Worker setup pre-built.
  • Next in the series: the same template runs on a backend too. We will look at Java, Node.js, Python, C#, PHP, and Rust backends. One per stack, so your team can pick the post that matches what you already ship.

If you build something with this, I'd love to see it. Drop a comment, or find the Oicana socials on oicana.com.

Top comments (0)