Programmatically filling PDF forms can be straightforward or tricky, depending on the setup. The challenge usually depends on whether you are writing everything from scratch, using a JavaScript library, how the form fields are named, and how well those names match your data model.
A more subtle challenge comes from the limitations of the libraries you choose. Most PDF form automation tools work by directly manipulating AcroForm fields, which means you are often responsible for validation, may need layout workarounds for repeating data, and have limited ability to modify or restructure fields once they are embedded in the PDF.
When populating PDF form fields with structured datasets like the following JSON data:
// service-request.json
[
{
"firstName": "John",
"lastName": "Doe",...
Programmatically filling PDF forms can be straightforward or tricky, depending on the setup. The challenge usually depends on whether you are writing everything from scratch, using a JavaScript library, how the form fields are named, and how well those names match your data model.
A more subtle challenge comes from the limitations of the libraries you choose. Most PDF form automation tools work by directly manipulating AcroForm fields, which means you are often responsible for validation, may need layout workarounds for repeating data, and have limited ability to modify or restructure fields once they are embedded in the PDF.
When populating PDF form fields with structured datasets like the following JSON data:
// service-request.json
[
{
"firstName": "John",
"lastName": "Doe",
"emailAddress": "john.doe@example.com",
"phoneNumber": "+1-555-123-4567",
"type": "Installation",
"priority": "High",
"description": "Need assistance setting up and configuring a new server rack in the data center.\nThis includes mounting all necessary equipment, installing power distribution units, managing cable organization, and ensuring proper ventilation and power balance.\n\nAfter the physical setup is complete, please verify all network connections, confirm switch and port configurations, and ensure that each device in the rack can successfully communicate across the network.\nProvide detailed documentation of configurations, cabling, and connectivity test results once verification is complete."
}
]
Most libraries simply map data into AcroForm fields and then flatten the PDF into static elements. Unlike most tools, Joyfill Form Builder and Filler SDK provides a more flexible abstraction layer that supports defining and validating fields, updating their structure over time, and conditionally showing or hiding elements, all without being tied to the PDF internal layout.
This guide shows how to use Joyfill to programmatically fill PDF forms in Node.js. We will cover reading fields, inserting data, and saving the PDF to create a reproducible workflow.
Why use Joyfill to Programmatically fill PDF Forms?
Traditional JavaScript PDF libraries focus on direct field manipulation. You parse the PDF, find matching field identifiers, inject values, then flatten everything. This approach works for small and stable forms, but it quickly breaks down as complexity grows.
A major limitation comes from the underlying PDF technologies themselves. PDF forms are built on two incompatible architectures: AcroForms and XFA (XML Forms Architecture). Most libraries only support one of them, usually AcroForms. As a result, XFA fields are often ignored entirely, leaving sections of your form blank when filled programmatically.
Even when fields are supported, developers still run into challenges when:
- Field names do not map cleanly to the data model
- The form structure needs to evolve
- Layout changes frequently
- Conditional visibility or computed values are required
- Business logic must live outside the PDF binary
Joyfill removes these constraints by representing PDF forms as a JoyDoc. The PDF becomes a visual layer, while the JoyDoc serves as the source of truth for field structure, data, metadata, and layout. Instead of interacting with brittle PDF internals, developers work with a predictable JSON model that remains stable, even when templates change. This is especially valuable when the same dataset needs to populate multiple document formats.
Key advantages for developers
You work with a JSON schema rather than PDF internals
JoyDoc represents your form in a structured JSON format. Each page, field, and resource is addressable through identifiers, making it simple to fetch, update, or compose documents through code rather than manual layout editing.
Every field has a stable identifier
Instead of guessing where values belong, you reference clear identifiers that do not change even if the document layout moves. This lowers maintenance cost and removes the need for repeated parsing.
Metadata lets you enrich documents
You can attach metadata to documents, pages, and fields. This enables structured workflows like tagging fields for export, versioning forms, or passing custom rules along with templates.
Formulas give fields logic
Formulas allow fields to compute values from other fields. For example, summing rows, handling default values, or applying conditional logic. No additional code is required for these computed results once the formulas are defined.
Better adaptability over time
If the PDF form changes, the identifiers and JSON structure allow you to update the document without rewriting your automation logic. You are not locked into the PDF internal layout.
Consistent PDF Forms
Most problems with programmatically filling PDF forms start long before any code runs. They are usually caused by how the form was authored. A poorly structured PDF form can make automation painful, no matter how clean your code is.
Standardize Form Structure Through JoyDoc
With Joyfill, this complexity disappears entirely. Forms created through Joyfill are represented as JoyDocs, a unified format that provides a consistent, predictable structure regardless of the underlying PDF technology. You don’t need to worry about incompatible form types, fields that fail to populate, or data that vanishes during processing. Everything is managed through a single, stable model that keeps your form definitions and data perfectly aligned, every time.
For example, to create a PDF form using Joyfill, start by setting up a Joyfill form builder environment.
- Create a new folder named
joyformand open it in your editor of choice.
Install the project dependencies using your preferred package manager.
npm install express ejs lowdb
Add a views/builder.ejs file to the project.
This file will serve as the main page for working with your form. It can load an existing template or create a new one when you open it in the browser.
<!-- views/builder.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Form Builder</title>
<script src=" https://cdn.jsdelivr.net/npm/@joyfill/components@latest/dist/joyfill.min.js"></script>
</head>
<body>
<div id="joyfill"></div>
<script>
<% if (form) { %>
const doc = <%- form %>;
<% } %>
<% if (!form) { %>
const doc = Joyfill.getDefaultDocument();
doc.name = <%- JSON.stringify(name) %>
<% } %>
Joyfill.JoyDoc(
document.getElementById('joyfill'),
{
doc,
mode: 'edit',
onChange: async (changelogs, updatedDoc) => {
// Log document changes to the console.
console.log(changelogs);
// Persist changes
try {
await fetch('/form', {
method: 'POST',
body: JSON.stringify(updatedDoc),
headers: {
'Content-Type': 'application/json',
},
});
console.log(`Successfully updated ${updatedDoc.name}`);
} catch (e) {
console.error(e);
alert(`Failed to update ${updatedDoc.name}: ${e.message}`);
}
},
}
);
</script>
</body>
</html>
Add an index.js file to the project and save the following script as its contents:
// index.js
const { JSONFilePreset } = require('lowdb/node');
const express = require('express');
(async () => {
// Create a data store for JoyDoc PDF form storage
const db = await JSONFilePreset('db.json', {
form: [],
});
// Configure an express application
const app = express();
app.set('view engine', 'ejs');
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
// Retrieve a PDF form for editing
app.get('/form/:name', async (req, res) => {
const { name } = req.params;
let form = db.data.form.find((d) => d.name === name);
res.render('builder', { form: JSON.stringify(form), name });
});
// Create or update form
app.post('/form', async (req, res) => {
const form = req.body;
const formIndex = db.data.form.findIndex((d) => d.name === form.name);
if (formIndex === -1) {
// Save new PDF form
db.data.form.push(form);
await db.write();
res.end();
return;
}
// Update existing form
db.data.form[formIndex] = form;
db.write();
res.status(200).end();
});
const port = 3000;
app.listen(port, () => {
console.log(`Form builder is listening on port ${port}`);
});
})();
Run the script to start the express server.
node index
Visit http://localhost:3000/form/service-request in your web browser. You should see the following PDF form builder.
Use Consistent, Meaningful Field Names
Automation depends on predictable field names. If your fields are named things like text1, text2, or field3, you will waste time figuring out what maps where during initial development and subsequent maintenance. To avoid this pitfall, always use clear, predictable names that match your data model (e.g., firstName, emailAddress, priority, etc.).
A few minutes spent creating a proper PDF form with consistent naming and accessible fields will save hours of debugging later. The cleaner the source form, the simpler the automation.
For example, add fields that you would normally find on a service request form to the form and give each field a human-friendly name through the identifier field as shown below.
At this point, the content of the project’s db.json file should look like the following:
// db.json
{
"form": [
{
"_id": "69042cc6f71bde2d6304d0f1",
"identifier": "doc_69042cc6f71bde2d6304d0f1",
"name": "service-request",
// form fields,
"fields": [
{
"file": "69042cc6913ba9fae414b0b4",
"_id": "69042cd8ca31952ab8f9c979",
"type": "block",
"title": "Heading Text",
"value": "Service Request",
"identifier": "field_69042cd8ca31952ab8f9c979"
},
{
"file": "69042cc6913ba9fae414b0b4",
"_id": "69042d04784f9cfbe61c5849",
"type": "text",
"title": "First Name",
"identifier": "firstName"
},
{
"file": "69042cc6913ba9fae414b0b4",
"_id": "69042d4e901859cad7a2e670",
"type": "text",
"title": "Phone Number",
"identifier": "phoneNumber"
},
{
"file": "69042cc6913ba9fae414b0b4",
"_id": "69042d6c44cc56d50f44fdbc",
"type": "text",
"title": "Email Address",
"identifier": "emailAddress"
},
{
"file": "69042cc6913ba9fae414b0b4",
"_id": "69042d7d758727e69c32a92d",
"type": "text",
"title": "Last Name",
"identifier": "lastName"
},
{
"file": "69042cc6913ba9fae414b0b4",
"_id": "69042e0ea372aeb632c9421b",
"type": "dropdown",
"title": "Service Type",
"options": [
{
"_id": "69042cc675376b64eddda857",
"value": "Yes",
"deleted": true
},
{
"_id": "69042cc6a82115b235236847",
"value": "No",
"deleted": true
},
{
"_id": "69042cc6ec92f77bcb4a3f65",
"value": "N/A",
"deleted": true
},
{
"_id": "69042e4713ed9ae0610ce289",
"value": "Installation",
"width": 100,
"deleted": false
},
{
"_id": "69042e66949791af17b4b077",
"value": "Maintenance & Repair",
"width": 100,
"deleted": false
},
{
"_id": "69042e73c4be2641732f4cb1",
"value": "Consultation",
"width": 100,
"deleted": false
},
{
"_id": "69042e7eba7a43ca20422a1e",
"value": "Technical Support",
"width": 100,
"deleted": false
}
],
"identifier": "type",
"value": ""
},
{
"file": "69042cc6913ba9fae414b0b4",
"_id": "69042eaefd0e2580a68764a8",
"type": "multiSelect",
"title": "Priority",
"multi": false,
"options": [
{
"_id": "69042cc6dd4dbf98302c7fdd",
"value": "Low",
"deleted": false
},
{
"_id": "69042cc6c63dca3d9dfc82f6",
"value": "Medium",
"deleted": false
},
{
"_id": "69042cc66bc177e4f7fdb55f",
"value": "High",
"deleted": false
},
{
"_id": "69042f57f9713f03bacca8dd",
"value": "Urgent",
"width": 0,
"deleted": false
}
],
"identifier": "priority"
},
{
"file": "69042cc6913ba9fae414b0b4",
"_id": "69042f96d4d94ea2103d9754",
"type": "textarea",
"title": "Service Description",
"identifier": "description",
"value": ""
}
]
}
]
}
Programmatically Fill the PDF Form
Now that we have a properly structured fillable form in the form of a JoyDoc, the next step is to map data to each form field. This data can come from JSON objects, database records, or other sources within your application. The goal remains the same: populate each PDF form field with its corresponding data field.
In the case of our current example project, using the service-requests.json file from the opening section of this post should suffice.
Add a fill-forms.js file to the project and save the following as its contents:
// fill-forms.js
// Retrieve service request PDF form
const form = require('./db.json').form.find(
(f) => f.name === 'service-request'
);
// Retrieve service requests from data source
const requests = require('./service-requests.json');
const puppeteer = require('puppeteer');
const { join } = require('path');
const { existsSync, mkdirSync, writeFileSync } = require('fs');
async function generatePDFs(requests) {
// Create output folder for filled out PDFs
if (!existsSync('pdfs')) {
mkdirSync('pdfs');
}
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.addScriptTag({
url: 'https://cdn.jsdelivr.net/npm/@joyfill/components@latest/dist/joyfill.min.js',
});
// Fill each form in a headless browser and save it to a PDF file.
for (let req of requests) {
const populatedForm = fillFormFields(req, form);
console.log(`Filling out ${req.firstName} ${req.lastName}'s form`);
await page.evaluate((populatedForm) => {
const container = document.createElement('div');
container.id = 'joyfill';
document.body.appendChild(container);
document.body.appendChild(container);
Joyfill.JoyDocExporter(container, {
doc: populatedForm,
config: { page: { height: 1056, width: 816, padding: 0 } },
});
}, populatedForm);
await page.pdf({
path: join('pdfs', `${populatedForm.name}.pdf`),
});
await page.evaluate(() => {
document.querySelector('#joyfill').remove();
});
}
await browser.close();
}
function fillFormFields(request, form) {
const filledDocument = { ...form };
// Set the PDF document's name
filledDocument.name = `${filledDocument.name} - ${request.firstName} ${request.lastName}`;
// Dynamically populate fields
for (let field of filledDocument.fields) {
const id = field.identifier;
switch (field.type) {
case 'multiSelect':
const selected = field.options.find(
(option) => option.value.trim() === request[id].trim()
);
field.value = [selected._id];
break;
case 'dropdown':
const option = field.options.find(
(option) => option.value.trim() === request[id].trim()
);
field.value = option._id;
break;
default:
field.value = request[id];
}
}
return filledDocument;
}
generatePDFs(requests)
.then(() => {
console.log('PDFs filled successfully');
})
.catch((e) => {
console.log('Failed to fill PDFs', e);
});
Add headless browser support to the project by running the following command:
npm install puppeteer
Run the fill-forms.js script.
npm fill-forms
This should fill out the form fields, flatten the fields as static elements, and save them to binary PDF files in the pdf subfolder of the project.
Filling Other Form Field Types
The sample project in this post shows how to programmatically populate text fields, dropdowns, and single-choice options. If you need to support additional field types, you would extend the logic in the fill-forms.js script. In practice, this means updating the switch statement to recognize each new field type and apply the appropriate handling behavior.
switch (field.type) {
case 'multiSelect':
const selected = field.options.find(
(option) => option.value.trim() === request[id].trim()
);
field.value = [selected._id];
break;
case 'dropdown':
const option = field.options.find(
(option) => option.value.trim() === request[id].trim()
);
field.value = option._id;
break;
case 'date':
field.value = new Date(request.date).getTime();
break;
case 'image':
case 'file':
const imageBuffer = fs.readFileSync(request.photoAttachment);
field.value = imageBuffer.toString('base64');
break;
case 'table':
// Where the value of request.table has the following interface
// Array<{
// _id: string; // row id
// deleted: boolean;
// cells: {
// [cell_id: string]: string; // column id
// };
// }>
field.value = request.table;
break;
case 'chart':
// Where each point has x (x axis coordinate), y (y coordiante), and a label field.
field.value[0].points = request.chart.map((point) => ({
...point,
_id: Joyfill.generateObjectId(),
}));
break;
default:
field.value = request[id];
}
Conclusion
Filling PDF forms with JavaScript is much simpler when you work with consistent structure instead of raw PDF internals. Joyfill makes this possible by standardizing forms as JoyDoc, keeping fields, metadata, and logic organized in one place.
This gives you reliable identifiers, predictable behavior, and flexibility as forms evolve. If you want a cleaner, more maintainable way to automate PDFs, Joyfill is an easy way to get there.
Need to build PDF capabilities inside your SaaS application? Joyfill helps developers embed native PDF and form experiences directly into their SaaS apps.