How to Add PDF Generation to an AI-Built App

To add PDF generation to an AI-built app, render the document server side from an HTML template, run it through a headless browser, and return a file. Do not generate PDFs in the browser, and do not ask the model to produce PDF bytes. The reliable stack is a template you control, a rendering...

Steve Jefferson
Steve Jefferson
Developer Advocate
15 August 20261 min read

To add PDF generation to an AI-built app, render the document server side from an HTML template, run it through a headless browser, and return a file. Do not generate PDFs in the browser, and do not ask the model to produce PDF bytes. The reliable stack is a template you control, a rendering service that runs on a schedule or a queue, and storage for the result. Everything hard about this is fonts, page breaks and timing, not the PDF format itself.

Invoices, quotes, reports and certificates are the four things every small app eventually needs to emit, and they are the features most likely to be built badly the first time.

Pick the approach before you write anything

There are three real options and they are not interchangeable.

Approach

Good for

Watch out for

Headless browser rendering HTML

Anything with layout: invoices, reports, statements

Font loading, memory, cold starts

A PDF library building the document programmatically

Fixed-layout forms, very high volume, strict determinism

Layout is code, so every design change is an engineering change

Filling a pre-made PDF template

Government and legal forms with fixed fields

Requires an existing form file, no layout freedom

For most apps built with AI, the first option wins by a wide margin, and for one reason: your team can already write HTML and CSS, and so can the model. A layout change becomes a template edit rather than a coordinate calculation. The second option earns its place at very high volume or when byte-for-byte reproducibility matters. The third is only for the case it describes.

The rest of this walks through the first approach.

Build the template as a real page first

Write the invoice as an HTML page you can open in a browser and iterate on. This is the single biggest time saver, because the feedback loop is a page refresh rather than a file download.

html
<!-- templates/invoice.html -->
<style>
  @page { size: A4; margin: 18mm 16mm 22mm 16mm; }
  body { font-family: "Inter", system-ui, sans-serif; font-size: 10.5pt; color: #111; }
  .items { width: 100%; border-collapse: collapse; }
  .items th { text-align: left; border-bottom: 1.5px solid #111; padding: 6px 0; }
  .items td { padding: 6px 0; border-bottom: 1px solid #ddd; }
  .items tr { break-inside: avoid; }
  thead { display: table-header-group; }  /* repeat headers across pages */
  tfoot { display: table-footer-group; }
  .totals { break-inside: avoid; }        /* never split the total block */
</style>

Those last four CSS rules are where most homegrown PDF features fail. @page gives you real print margins. display: table-header-group repeats the column headers when a long invoice runs onto a second page. break-inside stops a line item or a totals block being cut in half by a page boundary. None of this is guessable, and a model asked for "an invoice template" will usually omit all of it.

Render it server side

js
// api/render-pdf.js  (Node, using Playwright)
import { chromium } from 'playwright';

export async function renderPdf(html) {
  const browser = await chromium.launch({ args: ['--no-sandbox'] });
  try {
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: 'networkidle' });
    await page.evaluateHandle('document.fonts.ready');   // critical
    return await page.pdf({
      format: 'A4',
      printBackground: true,
      displayHeaderFooter: true,
      footerTemplate: `<div style="font-size:8pt;width:100%;text-align:center;color:#666">
        Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
      headerTemplate: '<div></div>',
      margin: { top: '18mm', bottom: '22mm', left: '16mm', right: '16mm' },
    });
  } finally {
    await browser.close();
  }
}

Three lines are doing unobvious work, and none of them are obvious from the page.pdf reference. document.fonts.ready waits for web fonts, without which you get a PDF rendered in a fallback font that looks nothing like your preview. printBackground: true is required or every coloured background silently disappears. And the footer template needs its own inline font size, because the header and footer are rendered in a separate context that does not inherit your page styles.

Do not run this in a request handler

A browser launch takes one to three seconds and hundreds of megabytes. Ten users clicking "download invoice" at once will exhaust a small server.

Put it behind a queue:

  1. The user requests a document. You create a job row and return a job id immediately.

  2. A worker picks up the job, renders, uploads the file to storage, and marks the row complete with a URL.

  3. The client polls the job, or you notify when it is done.

This is the standard pattern from adding background jobs to an AI-built app, and PDF generation is the most common reason a small app needs it. For a document that takes under a second and is requested rarely, a synchronous endpoint with a hard timeout is acceptable. Know which one you have.

Reuse the browser instance across jobs in a long-lived worker. Launching per document is the difference between a hundred milliseconds and three seconds, and at any volume it is also the difference between one worker and six.

Storage, not attachments

Write the finished PDF to object storage and hand out a time-limited signed URL. Do not return the bytes through your API, and do not email them as attachments if you can avoid it.

The reasons stack up. Large attachments hurt deliverability. Regenerating a document later is free if you kept the file and expensive if you did not. And a signed URL gives you an access log, which matters when the document is an invoice or a medical summary.

Naming convention worth adopting from the start: invoices/2026/08/INV-2026-0412.pdf. Date-partitioned paths make retention rules and bulk exports trivial later. The upload path is the same one described in adding file uploads to an AI-built app, pointed in the other direction.

Where the AI part belongs

If a model is involved, it should generate content, never layout and never the file.

Good uses: drafting the summary paragraph at the top of a monthly report, turning line items into a plain-English description, translating a document into a second language, writing the commentary section of an analytics export.

Bad uses: producing HTML for the whole page on every render, which makes output non-deterministic and turns a template bug into an unreproducible one. Generate content into a fixed template, and validate it before it lands in a document that goes to a customer.

One practical guard: cap the length of any generated field and check it before rendering. A model that returns four hundred words for a field the template allows two lines for will silently break your layout, and you will find out when a client forwards a screenshot.

The checks worth having before you ship

  • Render with the longest realistic data you have. Fifty line items, a company name that overflows, an address with five lines.

  • Render with the shortest. One item, no notes, no tax. Empty-state layouts break as often as overflowing ones.

  • Check a non-Latin character set if you serve one. The font that looks right in your browser may have no glyphs for it, and missing glyphs render as boxes in the PDF with no error anywhere.

  • Open the result in at least two viewers. Browser preview, a desktop reader, and a phone if the document is customer-facing.

  • Confirm the text is selectable. If it is not, something rendered the page as an image and your document is not searchable or accessible.

That last check catches a whole class of quiet failures, and it takes two seconds.

What this costs to run

Rendering is CPU and memory heavy, not network heavy. On a small container, budget roughly 300 to 500 MB of memory for a browser instance and expect a few hundred milliseconds per page once the browser is warm.

At low volume the whole feature costs less than the storage. At high volume, the worker fleet becomes a real line item, which is when the programmatic PDF library starts to look attractive despite the development cost. The threshold is usually somewhere in the tens of thousands of documents per day. What it costs to run an AI-built app has the wider budgeting picture, and the queue you built here is reusable for email sending too.

Frequently asked questions

Can I just use the browser's print to PDF? For a user-triggered download of something already on screen, yes, and it is free. For anything you need to store, email, or reproduce identically later, no. Client-side output varies by browser, operating system and installed fonts.

Do I need a paid PDF service? Not to start. A worker with a headless browser handles a surprising amount of volume. Paid services are worth it when you would rather not operate the worker, which is a legitimate preference.

How do I add a digital signature? That is a separate step after rendering, using a signing library and a certificate. Do not try to make the renderer do it, and check what "signature" means to your counterparty, since a drawn image of a signature and a cryptographic one are very different things.

Why does my PDF look different from the preview? Almost always fonts, then print styles. Wait for document.fonts.ready, self-host the font files rather than loading them from a third party, and check whether a @media print block is changing your layout. If you are still stuck, the wider debugging approach in building an app with AI applies: reduce to the smallest template that reproduces it.

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.