How to Fill PDF Forms in the Browser Using pdf-lib

PDF forms are everywhere - government applications, tax documents, legal contracts. And for most of the web's history, filling them programmatically meant server-side processing: upload the file, run it through a Python or Java library, download the result.

That's no longer the only option.

pdf-lib is a pure JavaScript library that runs entirely in the browser. No server. No upload. No backend. You can open a PDF, fill its form fields, and export a completed document - all client-side. This post walks through exactly how to do that.

 

What is pdf-lib?

pdf-lib is an open-source JavaScript library for creating and modifying PDFs. It handles both creation from scratch and modification of existing documents, including AcroForm fields — the standard format for fillable PDF forms.

It works in the browser via a single minified file, or via npm for Node.js environments.

 

Setup

The quickest way to get started is via CDN. Add this to your HTML:

<script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.js"></script>

That's it. No build step, no package manager. PDFLib is now available globally.

If you're using npm:

npm install pdf-lib
import { PDFDocument } from 'pdf-lib';

Step 1: Load the PDF

First, fetch the PDF and convert it to an ArrayBuffer - the raw binary format pdf-lib expects.

const formUrl = '/forms/i-765.pdf';
const formBytes = await fetch(formUrl).then(res => res.arrayBuffer());
const pdfDoc = await PDFLib.PDFDocument.load(formBytes);

If you're letting users upload a PDF file locally, use FileReader instead:

const file = document.getElementById('fileInput').files[0];

const formBytes = await new Promise((resolve, reject) => {
  const reader = new FileReader();
  reader.onload = () => resolve(reader.result);
  reader.onerror = () => reject(new Error('Failed to read file'));
  reader.readAsArrayBuffer(file);
});

const pdfDoc = await PDFLib.PDFDocument.load(formBytes);

 

Step 2: Access the Form Fields

Once you've loaded the PDF, get a reference to its form object:

const form = pdfDoc.getForm();

 To see all available fields in the PDF (useful when mapping a form for the first time):

const fields = form.getFields();
fields.forEach(field => {
  console.log(field.getName(), field.constructor.name);
});

This will print each field's name and type - PDFTextField, PDFCheckBox, PDFRadioGroup, PDFDropdown, etc. You'll need these names to fill them programmatically.

 

Step 3: Fill the Fields

Text fields:

const nameField = form.getTextField('form1[0].#subform[0].Pt1Line1a_FamilyName[0]');
nameField.setText('Doe');

Checkboxes

const checkbox = form.getCheckBox('form1[0].#subform[0].Pt2Line1_CB_Yes[0]');
checkbox.check();
// or
checkbox.uncheck();

Radio buttons:

const radioGroup = form.getRadioGroup('form1[0].#subform[0].Pt3Line1_CitizenshipStatus[0]');
radioGroup.select('1'); // value depends on the PDF's defined options

Dropdowns:

const dropdown = form.getDropdown('form1[0].#subform[0].Pt2Line4_StateOrTerritory[0]');
dropdown.select('California');

 

A practical pattern is to define your data as a plain object and loop through it:

const formData = {
  'Pt1Line1a_FamilyName[0]': 'Doe',
  'Pt1Line1b_GivenName[0]': 'Jane',
  'Pt1Line3_DateOfBirth[0]': '01/15/1990',
};

for (const [fieldName, value] of Object.entries(formData)) {
  try {
    const field = form.getTextField(`form1[0].#subform[0].${fieldName}`);
    field.setText(String(value));
  } catch (e) {
    console.warn(`Could not fill field: ${fieldName}`, e);
  }
}

 

The try/catch per field is important - if a field name doesn't exist in the PDF, pdf-lib throws. You don't want one bad field to abort the whole operation.

 

Step 4: Flatten (Optional but Important)

By default, filled fields remain interactive — the user can still edit them. If you want to lock the values and produce a static PDF (useful for final submissions or archiving), flatten the form:

form.flatten();

This converts all form fields into static page content. The output looks identical, but the fields are no longer editable. Do this before saving if you're producing a finalized document.

 

⚠️ Flattening is irreversible on that document instance. Save the unflatted version separately if you need to preserve editability.

Step 5: Save and Download

Serialize the modified PDF and trigger a browser download:

const pdfBytes = await pdfDoc.save();

const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);

const link = document.createElement('a');
link.href = url;
link.download = 'filled-form.pdf';
link.click();

URL.revokeObjectURL(url); // clean up

 

The user gets a completed, filled PDF downloaded directly to their device. No server involved at any point.

Putting It All Together

Here's the full minimal example:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>PDF Form Filler</title>
  <script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script>
</head>
<body>
  <button id="fillBtn">Fill and Download PDF</button>

  <script>
    document.getElementById('fillBtn').addEventListener('click', async () => {
      const formBytes = await fetch('/forms/sample.pdf').then(r => r.arrayBuffer());
      const pdfDoc = await PDFLib.PDFDocument.load(formBytes);
      const form = pdfDoc.getForm();

      const data = {
        'firstName': 'Jane',
        'lastName': 'Doe',
        'dateOfBirth': '01/15/1990',
      };

      for (const [name, value] of Object.entries(data)) {
        try {
          form.getTextField(name).setText(value);
        } catch (e) {
          console.warn('Skipping field:', name);
        }
      }

      const pdfBytes = await pdfDoc.save();
      const blob = new Blob([pdfBytes], { type: 'application/pdf' });
      const link = document.createElement('a');
      link.href = URL.createObjectURL(blob);
      link.download = 'filled.pdf';
      link.click();
    });
  </script>
</body>
</html>

 

A Note on XFA Forms

If you've tried this on a government PDF and gotten an error, there's a good chance the form is XFA-based rather than AcroForm.

XFA (XML Forms Architecture) is an older Adobe format that stores form structure as embedded XML. pdf-lib does not support XFA - almost no JavaScript library does. XFA forms require Adobe-specific rendering engines and will either display as blank in the browser or throw an error when you try to access their fields.

This is exactly the problem that affects most USCIS and State Department forms. They're XFA PDFs, which is why they fail in Chrome and Firefox and why filling them programmatically is so hard.

If you're dealing with XFA forms, the approach is different: you need to extract the XML schema, re-implement the form as a native web form, and then write the completed data back into an AcroForm-compatible PDF using pdf-lib. It's more work, but it's the only way to make these forms genuinely usable in a modern browser.

 

What You Can Build With This

The pattern above - load PDF, fill fields, download - is the foundation for a lot of useful tools:

  • Auto-fill forms from a user profile or stored data
  • Batch-generate filled PDFs from a spreadsheet or JSON
  • Pre-populate forms for clients before sending for review
  • Build a web UI on top of a complex PDF form so users never touch the raw document

All of it runs client-side. All of it is free to build with. The library is MIT-licensed.

 


This is the core technique behind Fillvisa — a free browser tool that converts US immigration forms into clean web forms and exports completed, submission-ready PDFs using pdf-lib. If you're working with government PDFs specifically, it's worth a look.