Get started
Generate fillable PDF forms from HTML: AcroForm, pdf-lib, and the API

Generate fillable PDF forms from HTML: AcroForm, pdf-lib, and the API

HTML forms do not auto-become PDF forms when you print. Here are the three working paths to ship interactive, fillable PDFs from a code-first workflow.

16 min read

HTML forms do not auto-become PDF forms when you print to PDF. The browser rasterizes every <input>, <select>, and <textarea> into static page content, and the output has no interactivity. To ship an interactive, fillable PDF, you have to inject AcroForm widgets with a PDF library after the page is rendered. This guide covers the three working paths, the field types each one supports, and when to flatten versus keep the form editable.

PDFMonkey published a builder-focused walkthrough on February 24, 2026 that assumes you stay inside their visual editor. This article is the counter-angle: code-first, tool-agnostic, and honest about the constraints every HTML-to-PDF pipeline runs into.

Why printing an HTML form to PDF produces a flat PDF

Headless Chrome flattens form widgets the moment it generates the PDF, because its print backend was built for paper. The render path takes the DOM, runs layout, then walks every element and emits text, vector paths, and raster images into the page content stream. There is no branch that maps an <input> element to an AcroForm widget annotation, because the AcroForm specification has nothing to do with HTML.

The same is true for Playwright, Puppeteer, wkhtmltopdf, WeasyPrint, and any other engine that ships a Chromium or WebKit print backend. WeasyPrint added partial fillable-form support in 2022 for a narrow subset of inputs, but it produces fields with no appearance streams, which means most readers display them as blank rectangles. For everyone else, the same rule applies: the print step is one-way, from interactive DOM to static page content.

This is why a SaaS pipeline that promises "HTML to PDF" almost always means "static HTML to static PDF." To ship a fillable PDF, you have to add a second step that walks the rendered page and injects widgets at known coordinates. That is the pattern this article documents.

AcroForm vs XFA: pick AcroForm

There are two PDF form technologies and only one of them is worth using today.

PropertyAcroFormXFA
StandardISO 32000-1 ยง12.7 (PDF 1.7)Adobe XFA 3.3 spec
ISO 32000-2 (PDF 2.0)Still mandatoryRemoved
Acrobat / ReaderYesYes
Apple PreviewYesNo
Chrome / EdgeYesNo
FirefoxYesNo
Foxit ReaderYesPartial
Mobile readersYesAlmost never
StoragePDF objects, fields and widgetsEmbedded XML document
ScriptingJavaScript (limited)JavaScript (extensive)

XFA (XML Forms Architecture) was Adobe's attempt to layer a full XML form language on top of PDF. It was deprecated in ISO 32000-2 in 2017 and is on track for removal from Acrobat itself. Chromium, Firefox, Apple Preview, and most mobile readers have never supported it. Anyone still recommending XFA for new work is out of date.

AcroForm is the universal standard. It defines five core widget types (text, button, choice, signature, and the generic widget) plus subtypes for checkboxes, radio buttons, dropdowns, list boxes, and push buttons. Every modern reader renders them. Every PDF library can create them. This is the only technology to target.

Three working architectures

There are three patterns to ship a fillable PDF from a code-first workflow. They differ in how much HTML you keep, how much PDF library code you write, and where the form definition lives.

PathSource of layoutField placementBest for
AHTML rendered to PDFInject widgets by data-field-name coordinatesForms that share a layout with an HTML preview
BStatic template PDFHardcoded coordinates in pdf-libGovernment forms, third-party templates, one-off documents
CServer-side definitionAPI renders both HTML and AcroForm outputSaaS that ships per-customer forms

Each path is covered in detail below. None of them get rid of the second step: you always need to run pdf-lib (or a similar AcroForm-capable library) after the HTML rendering.

Path A: HTML to Chromium to pdf-lib post-process

This is the most common pattern in production. You author the layout once in HTML with markers where the fields should land, render to PDF with PDF4.dev or Playwright, then walk the resulting PDF and inject widgets at the marker positions.

The trick is to mark each field position with a transparent box in the HTML, tagged with a data-field-name attribute. The rendered PDF preserves the box's bounding rectangle, and pdf-lib can find that rectangle and drop a widget on top.

<!DOCTYPE html>
<html>
  <head>
    <style>
      body { font-family: Inter, sans-serif; padding: 40px; }
      .label { font-size: 14px; color: #666; }
      .field {
        display: block;
        width: 320px;
        height: 32px;
        border: 1px solid #ccc;
        background: rgba(124, 58, 237, 0.06);
        margin: 4px 0 16px 0;
      }
    </style>
  </head>
  <body>
    <h1>Sales contract</h1>
 
    <p class="label">Full name</p>
    <div class="field" data-field-name="full_name"></div>
 
    <p class="label">Email</p>
    <div class="field" data-field-name="email"></div>
 
    <p class="label">Signed on</p>
    <div class="field" data-field-name="signed_at" data-field-type="signature"></div>
  </body>
</html>

The key detail is the coordinate conversion. CSS pixels run top-down at 96 DPI. PDF points run bottom-up at 72 DPI. The conversion is pt = px * 0.75 for distances, plus a Y-axis flip relative to page.getHeight(). Get that wrong and your fields land off-page or upside-down.

This pattern works with the PDF4.dev render API just as well as with raw Playwright. Render the HTML with PDF4.dev to get the visual PDF, then run the same post-process step locally to inject the widgets.

Path B: pure pdf-lib

When you have a fixed template PDF, like a government form or a third-party document, there is no HTML in the loop. You open the template, place widgets at known coordinates, save. This is the simplest path and the most common in compliance and legal pipelines.

import { PDFDocument, PDFName, PDFBool, rgb } from "pdf-lib";
import { readFileSync, writeFileSync } from "fs";
 
async function buildFillableForm(templatePath: string, outputPath: string) {
  const bytes = readFileSync(templatePath);
  const doc = await PDFDocument.load(bytes);
  const form = doc.getForm();
  const [page] = doc.getPages();
 
  // Text field
  const nameField = form.createTextField("full_name");
  nameField.setText("");
  nameField.addToPage(page, {
    x: 120,
    y: 700,
    width: 320,
    height: 22,
    borderColor: rgb(0.7, 0.7, 0.7),
  });
 
  // Checkbox
  const consent = form.createCheckBox("agree_to_terms");
  consent.addToPage(page, {
    x: 120,
    y: 660,
    width: 14,
    height: 14,
  });
 
  // Radio group
  const plan = form.createRadioGroup("plan");
  plan.addOptionToPage("monthly", page, { x: 120, y: 620, width: 14, height: 14 });
  plan.addOptionToPage("yearly", page, { x: 200, y: 620, width: 14, height: 14 });
 
  // Dropdown (combo)
  const country = form.createDropdown("country");
  country.addOptions(["France", "Germany", "United Kingdom", "United States"]);
  country.select("France");
  country.addToPage(page, { x: 120, y: 580, width: 200, height: 22 });
 
  // Listbox (multi-select)
  const interests = form.createOptionList("interests");
  interests.addOptions(["Engineering", "Design", "Product", "Sales"]);
  interests.addToPage(page, { x: 120, y: 500, width: 200, height: 70 });
 
  // NeedAppearances tells older readers to draw widgets
  form.acroForm.dict.set(PDFName.of("NeedAppearances"), PDFBool.True);
 
  writeFileSync(outputPath, await doc.save());
}
 
buildFillableForm("blank-contract.pdf", "fillable-contract.pdf");

The full pdf-lib form API covers every AcroForm field type. Field names must be unique inside the document and must not contain a dot (the dot is a hierarchical separator in AcroForm).

Path C: server-side via PDF4.dev

For a SaaS that ships per-customer forms, you want the form definition to live in your database, not in source code. The pattern is to store a JSON definition that describes both the visual HTML layout and the AcroForm field list, then have your render endpoint produce two outputs: a flat preview PDF for display, and a fillable PDF for download.

The PDF4.dev render API today produces flat PDFs because it runs on a Chromium backend. The pattern to layer on top is a --keep-forms flag (or delivery: "fillable" in the request body) that triggers a follow-up pdf-lib step on the server. Until that ships natively, run the post-process in your own backend.

// Conceptual: the future shape
const response = await fetch("https://api.pdf4.dev/v1/render", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PDF4_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    template_id: "contract",
    data: { customer_name: "Acme Corp" },
    delivery: "fillable",
    fields: [
      { name: "signature", type: "signature", anchor: "data-field-name=signature" },
      { name: "signed_at", type: "text", anchor: "data-field-name=signed_at" },
    ],
  }),
});

The anchor lookup matches Path A: place a tagged <div data-field-name="..."> in the template and let the server resolve coordinates from the rendered DOM, then drop the widget. The same pattern works against any HTML-to-PDF service that exposes the rendered page geometry.

Field types covered

AcroForm defines six widget types worth using in production. Push button, choice, signature, and text cover everything most workflows need. File attachment exists in the spec but is rarely rendered by mobile readers.

Typepdf-lib methodISO 32000 nameAcrobatPreviewChromeFoxit
TextcreateTextField/TxYesYesYesYes
CheckboxcreateCheckBox/Btn (with Pushbutton and Radio flags off)YesYesYesYes
Radio groupcreateRadioGroup/Btn (Radio flag)YesYesYesYes
Dropdown (combo)createDropdown/Ch (Combo flag)YesYesYesYes
ListboxcreateOptionList/ChYesYesPartialYes
Push buttoncreateButton/Btn (Pushbutton flag)YesYesYesYes
Signaturen/a (low-level API)/SigYesYes (display only)NoYes
File attachmentn/a/Tx with /FileSelectYesNoNoPartial

Signature widgets need the low-level pdf-lib API or a signing service like DocuSeal to attach a real cryptographic signature. The visible widget rectangle is a regular button or text field; the signature dictionary is attached separately at signing time.

When to flatten vs keep editable

The flatten-or-keep decision is downstream of the document's lifecycle.

StageFlatten?Why
Draft sent for reviewNoReviewers add comments and fill values
Filled by customerNoCustomer needs the widgets to be interactive
Signed contractMixedFlatten body, keep signature widget editable until signed
Final archivalYesRequired for PDF/A, prevents silent edits
Tax submissionYesReceiving authority expects static content
Internal printYesPrint drivers ignore non-printing widgets

The most useful pattern is mixed: keep the signature widget editable so the signing tool can attach a cryptographic signature, flatten everything else so the body cannot be changed before signing. The PDF4.dev flatten PDF tool does this in a browser with no upload, and the how to flatten a PDF guide covers the pdf-lib, PyMuPDF, Ghostscript, and qpdf paths for batch flatten.

Validation: do not trust the filled PDF

A non-signed AcroForm value can be edited by anyone with a PDF reader, including Apple Preview or a free online editor, without leaving any audit trail. Treat the returned PDF as transport, not as the source of truth.

The server-side validation pattern:

import { PDFDocument } from "pdf-lib";
 
async function validateSubmission(uploadedBytes: Uint8Array) {
  const doc = await PDFDocument.load(uploadedBytes);
  const form = doc.getForm();
 
  const name = form.getTextField("full_name").getText() || "";
  const email = form.getTextField("email").getText() || "";
  const agreed = form.getCheckBox("agree_to_terms").isChecked();
  const plan = form.getRadioGroup("plan").getSelected();
 
  if (!name || name.length > 200) throw new Error("Invalid name");
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw new Error("Invalid email");
  if (!agreed) throw new Error("Terms must be accepted");
  if (plan !== "monthly" && plan !== "yearly") throw new Error("Invalid plan");
 
  return { name, email, plan };
}

Validate every field server-side, run your business logic against the validated values, persist the result to your database, and only then store the PDF as evidence. If the workflow is high-stakes (financial, legal, healthcare), require a signed PDF for the audit trail and verify the signature before reading the fields.

Sign and lock the form

Combining AcroForm with a digital signature gives you a tamper-evident document. The signature hashes the entire byte content of the file at signing time. Any later edit, including changing a single field value, breaks the signature and the reader displays a "signature invalid" banner.

The signing flow:

  1. Render the layout to a flat PDF
  2. Inject AcroForm widgets with pdf-lib
  3. The user fills the fields on their device
  4. A signing service like DocuSeal, Dropbox Sign, or Adobe Acrobat Sign attaches a cryptographic signature
  5. To prevent further edits, the signature is a certifying signature with the DocMDP permissions level set to 1 (no changes allowed)

After step 5, the document is read-only at the cryptographic level. Any opening reader will refuse to mark the signature valid if the bytes have changed. This is the strongest enforcement available for tamper resistance, much stronger than password protection, which only gates the open action.

Common bugs

Blank-looking fields in older readers. This is the missing NeedAppearances flag. AcroForm widgets carry either a precomputed appearance stream or a flag telling the reader to compute one at display time. Acrobat and modern readers compute appearances on the fly; older readers and some mobile apps draw nothing if no appearance stream exists and the flag is not set. Always set it:

form.acroForm.dict.set(PDFName.of("NeedAppearances"), PDFBool.True);

Text overflow in fixed-width fields. A text field with multiline: false truncates at the widget border but still stores the full value. The visual is wrong but the data is correct. To make the visual match, either widen the widget, shrink the font, or set the field to multiline so it word-wraps:

const field = form.createTextField("description");
field.enableMultiline();
field.addToPage(page, { x: 120, y: 400, width: 360, height: 90 });

Font not embedded. AcroForm widgets without an explicitly assigned font fall back to Helvetica, which has no glyphs for non-Latin scripts. If your form will be filled in Cyrillic, Arabic, or CJK, embed a font and assign it:

import { StandardFonts } from "pdf-lib";
 
const customFont = await doc.embedFont(StandardFonts.Helvetica);
field.updateAppearances(customFont);

For non-Latin scripts, embed a TrueType font with doc.embedFont(fontBytes) instead of the standard 14.

Field names with dots. AcroForm treats a dot in a field name as a hierarchical separator. customer.name creates a parent group named customer with a child named name, which is almost never what you want. Use underscores or camelCase: customer_name, customerName.

Lost values after re-saving with another tool. Some PDF tools strip widgets they do not understand or rewrite the AcroForm dictionary inconsistently. After any third-party processing step, re-read the field values with pdf-lib and verify the AcroForm dictionary still exists at the document catalog level.

Wrap-up

The honest summary: there is no one-step "HTML to fillable PDF" pipeline in production today, because the browser print backend was never built to emit AcroForm widgets. Every working architecture pairs an HTML rendering step with a pdf-lib post-process that injects widgets at known coordinates.

For one-off documents on a static template, Path B (pure pdf-lib) is the shortest path. For forms that share a layout with an HTML preview, Path A (HTML to Chromium to pdf-lib) keeps a single source of truth. For SaaS shipping per-customer forms, Path C (server-side definition with a PDF4.dev-style render flow) is the right shape, and the data-field-name anchor pattern is what makes it scale.

Whatever path you pick, treat the returned PDF as transport, not as the source of truth. Re-read every field server-side, run your business rules against the validated values, and require a digital signature for any document that carries legal or financial weight.

For the related operations, see the how to flatten a PDF guide for locking values once a form is filled, how to prevent PDF editing for read-only protection without flattening, and PDF generation best practices for the broader pipeline patterns.

Free tools mentioned:

Flatten PdfTry it freeHtml To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.