> ## Documentation Index
> Fetch the complete documentation index at: https://developers.zite.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Generate PDFs

> Render an HTML document to a hosted PDF — invoices, receipts, reports — from a backend workflow with ZitePdf.renderHtml.

No setup or integration needed — call [`ZitePdf.renderHtml`](/framework/utilities#pdf-generation) from a backend [workflow](/framework/workflows) and you get back a permanent hosted URL to store, email, or return to the frontend.

## Worked example — an invoice

```typescript theme={null}
// apps/<app>/src/api/generateInvoice.ts
import { z } from 'zod';
import { createEndpoint } from 'zitejs/backend';
import { ZitePdf } from 'zitejs/pdf';
import { zite } from 'zitejs/db';

export default createEndpoint({
  description: 'Render an invoice PDF and save its URL',
  inputSchema: z.object({
    invoiceNumber: z.string(),
    customer: z.object({ name: z.string(), email: z.string() }),
    lineItems: z.array(z.object({
      description: z.string(),
      quantity: z.number(),
      unitPrice: z.number(),
    })),
  }),
  outputSchema: z.object({ url: z.string() }),
  execute: async ({ input }) => {
    // Escape every user-controlled string you interpolate into the HTML.
    const esc = (s: unknown) =>
      String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
        .replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    // Coerce numbers so NaN never reaches the document.
    const num = (n: unknown) => (Number.isFinite(Number(n)) ? Number(n) : 0);

    const total = input.lineItems.reduce((s, li) => s + num(li.quantity) * num(li.unitPrice), 0);
    const rows = input.lineItems.map(li => {
      const qty = num(li.quantity), unit = num(li.unitPrice);
      return `<tr>
        <td>${esc(li.description)}</td>
        <td class="num">${qty}</td>
        <td class="num">$${unit.toFixed(2)}</td>
        <td class="num">$${(qty * unit).toFixed(2)}</td>
      </tr>`;
    }).join('');

    const html = `<!doctype html>
<html><head><meta charset="utf-8" /><style>
  @page { size: letter; margin: 0.75in; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #111827; font-size: 11pt; }
  h1 { font-size: 24pt; margin: 0 0 18px; }
  table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
  th { text-align: left; border-bottom: 1.5px solid #cbd5e1; padding: 8px 0; font-size: 9pt; color: #475569; }
  td { padding: 8px 0; border-bottom: 1px solid #f1f5f9; }
  td.num, th.num { text-align: right; }
  tfoot td { border-top: 1.5px solid #cbd5e1; font-weight: 700; }
</style></head>
<body>
  <h1>Invoice #${esc(input.invoiceNumber)}</h1>
  <p>Bill to ${esc(input.customer.name)} · ${esc(input.customer.email)}<br />${new Date().toLocaleDateString()}</p>
  <table>
    <thead><tr><th>Description</th><th class="num">Qty</th><th class="num">Unit</th><th class="num">Amount</th></tr></thead>
    <tbody>${rows}</tbody>
    <tfoot><tr><td colspan="3" class="num">Total</td><td class="num">$${total.toFixed(2)}</td></tr></tfoot>
  </table>
</body></html>`;

    const { url } = await ZitePdf.renderHtml({ html, filename: `invoice-${input.invoiceNumber}.pdf` });

    // Optional: keep the URL on a record so it shows up in the app.
    await zite.Invoices.create({ record: { pdfUrl: url, customerName: input.customer.name } });
    return { url };
  },
});
```

`renderHtml` returns `{ url, filename }` — `url` is a permanent, public HTTPS download link.

## HTML authoring rules

* **One self-contained document** — `<!doctype html>`, a `<head>`, one `<style>` block. Avoid external `<link>`s (CSS / font CDNs); they time out in headless Chromium.
* **Set page size and margins** at the top of the stylesheet: `@page { size: letter; margin: 0.75in; }` (`size: a4` only for metric paper).
* **Escape user input** and **coerce numbers** (`Number.isFinite(...) ? … : 0`) before `.toFixed()`.
* **Variable-length sections** — `array.map(...)` returning row HTML strings, `.join('')` into the table body.
* **`font-variant-numeric: tabular-nums`** on tables of numbers so columns align.

## Constraints

* **Max output 25 MB** — paginate with `@page` or split large reports.
* **No JS execution** in the rendered HTML — keep everything static.
* **No private-network URLs** (RFC1918 / `file://` / metadata IPs) — the renderer refuses them.

To "fill out a PDF," reproduce its layout in HTML and substitute values — there's no AcroForm-fill path.
