Build a Fillable Invoice PDF Template
Generate a professional fillable PDF invoice with form fields using Node.js.
Introduction
30 Second Summary
Every business sends invoices, but manually filling out PDF templates field by field is slow and error-prone. What if you could generate a professional, branded invoice template entirely from code?
In this project, you will build a fillable PDF invoice generator using Node.js and the pdf-lib library. You'll create a reusable template with a custom header, interactive form fields, and a script that fills the template with data automatically.
What You'll Build
A Node.js script that generates a professional invoice PDF template with an embedded logo, fillable form fields, and a second script that populates the template with sample data.
By the end of this project, you'll have:
- A custom branded header with an embedded logo image and styled text inside a generated PDF.
- Fillable AcroForm fields including text inputs, a dropdown menu, and a checkbox.
- A fill script that programmatically populates the template with sample invoice data, proving it is reusable.
- Secret Mission: Add a multiline notes section and flatten the form into a locked, non-editable final PDF.
Are there any prerequisites?
You should have basic JavaScript knowledge (variables, functions, async/await) and be comfortable running commands in a terminal. No prior experience with PDF generation is needed.
Set Up Your Project
Before you can generate any PDFs, you need a working project environment with the right tools in place.
In this step, you will create a project folder, install the pdf-lib package for PDF generation, and confirm everything runs without errors.
In this step, get ready to:
- Create and initialize a project folder with npm.
- Install the pdf-lib package for PDF generation.
- Verify your setup runs without errors.
Create your project folder and initialize npm
Every Node.js project starts with a folder and a package.json file that tracks your project's name, dependencies, and scripts.
- Open the integrated terminal in VS Code by pressing Ctrl+` (macOS) or Ctrl+` (Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir pdf-invoice-template
cd pdf-invoice-template
- Initialize your project with npm by running:
npm init -y
- Confirm the folder and file exist by running:
ls
You should see package.json listed in the output.
Don't see package.json?
Make sure you ran npm init -y inside the pdf-invoice-template folder, not on your Desktop. Run pwd to check your current directory.
help me figure out why npm init didn't create my package.json
The generated package.json has default values that you need to update for this project.
- Open package.json in VS Code by clicking on it in the Explorer sidebar.
- Select all the existing content and replace it with the following:
{
"name": "pdf-invoice-template",
"version": "1.0.0",
"type": "module",
"scripts": {
"create": "node create-template.js",
"fill": "node fill-template.js"
}
}
What does this configuration do?
- "type": "module" enables ES module import syntax instead of the older require() style.
- The scripts section defines shortcut commands you can run with npm run create and npm run fill.
- Save package.json.
- Install the pdf-lib package by running:
npm install pdf-lib
You should see output confirming that packages were added successfully.
What did npm install do?
This command downloaded the pdf-lib library and saved it in a node_modules folder. It also added a dependencies section to your package.json automatically.
Seeing permission errors or EACCES?
Try running the command with elevated permissions, or check that you own the project folder. Make sure you are inside the pdf-invoice-template directory.
help me fix npm install permission errors
Prepare a logo image
Your invoice template will feature a branded header with an embedded logo. The script will scale the image automatically, so any PNG file works.
- Find any PNG image on your computer (a company logo, an icon, or any image you have on hand).
- Copy or move the image file into your pdf-invoice-template folder.
- Rename the file to logo.png.
- Confirm the file is in place by running:
ls
You should see logo.png, package.json, and node_modules in the output.
Don't have a PNG handy?
You can download any free icon from the web, or take a screenshot of something simple and save it as a PNG. The image content does not matter for this project. Just make sure the file extension is .png (not .jpg or .webp).
Create the main script file and verify your setup
Now you will create the script that will eventually generate your invoice template. For now, you will add a simple test to confirm pdf-lib loads correctly.
- Create a new file called create-template.js in your project folder.
- Add the following code to create-template.js:
import { PDFDocument } from 'pdf-lib';
console.log('pdf-lib loaded successfully');
What does this code do?
- import { PDFDocument } from 'pdf-lib' pulls in the main class from pdf-lib that you will use to create PDF documents.
- The console.log line prints a success message to confirm the import worked without errors.
- Save create-template.js.
- Run the script to verify your setup:
node create-template.js
You should see pdf-lib loaded successfully printed in your terminal.
Seeing an error instead?
- If you see Cannot find package 'pdf-lib', make sure you ran npm install pdf-lib in the same folder where create-template.js lives.
- If you see Cannot use import statement, check that your package.json contains "type": "module".
help me debug my create-template.js import error
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is what each file should look like at this point.
create-template.js:
import { PDFDocument } from 'pdf-lib';
console.log('pdf-lib loaded successfully');
package.json:
{
"name": "pdf-invoice-template",
"version": "1.0.0",
"type": "module",
"scripts": {
"create": "node create-template.js",
"fill": "node fill-template.js"
},
"dependencies": {
"pdf-lib": "^1.17.1"
}
}
You should also have a logo.png file and a node_modules folder in your project directory.
Your project environment is ready. Next up, you will create the PDF document and build a custom branded header with an embedded logo.
Create the PDF Document and Custom Header
Your project is set up and ready to go. Now it's time to build the actual PDF document from scratch.
Every professional invoice starts with a branded header. In this step, you'll create a PDF document, draw a colored header bar, embed your logo image, and add your business name as styled text.
In this step, get ready to:
- Create a PDF document with a US Letter-sized page.
- Draw a custom branded header with a colored bar, logo, and text.
- Save and verify the PDF renders correctly.
Set up the PDF document and page
PDFs use a coordinate system where the origin (0, 0) is at the bottom-left corner of the page. US Letter pages are 612 points wide and 792 points tall. You'll set up the document skeleton now, including the save logic at the bottom, so you can run and preview the output after every change.
- In create-template.js, select all the existing code and replace it with this:
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import fs from 'node:fs';
async function createInvoiceTemplate() {
// Create an empty PDF document
const pdfDoc = await PDFDocument.create();
// Add a US Letter page (612pt wide, 792pt tall)
const page = pdfDoc.addPage([612, 792]);
// Embed the Helvetica font for drawing text later
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
// --- SAVE ---
const pdfBytes = await pdfDoc.save();
fs.writeFileSync('./invoice-template.pdf', pdfBytes);
console.log('Invoice template created: invoice-template.pdf');
}
createInvoiceTemplate();
What does this code do?
- The imports pull in PDFDocument (the document builder), StandardFonts (built-in font names), and rgb (color helper) from pdf-lib.
- PDFDocument.create() creates a blank PDF in memory. addPage([612, 792]) adds a page with US Letter dimensions in points.
- embedFont(StandardFonts.Helvetica) loads the Helvetica font so you can draw text on the page.
- The save section at the bottom serializes the PDF to bytes and writes the file to disk. You'll keep adding content above it.
- Save create-template.js.
- Run the script to confirm it produces a valid PDF by running:
node create-template.js
- You should see Invoice template created: invoice-template.pdf in the terminal.
- Open invoice-template.pdf in any PDF reader. You should see a blank white page.
Seeing an error?
- If you see Cannot find module 'pdf-lib', make sure you ran npm install pdf-lib in the pdf-invoice-template folder.
- If you see a syntax error about import, check that your package.json contains "type": "module".
- help me debug my create-template.js setup error
Draw the custom header
The header has three layers: a dark blue background rectangle, your logo image scaled to fit, and white text for the business name and tagline. You'll build each layer on top of the previous one.
- In create-template.js, find the line that says // --- SAVE ---.
- Add the header background and logo code directly above it:
// --- CUSTOM HEADER ---
// Draw a dark blue rectangle as the header background
page.drawRectangle({
x: 0,
y: 692,
width: 612,
height: 100,
color: rgb(0.05, 0.2, 0.4),
});
// Read and embed the logo image from disk
const logoBytes = fs.readFileSync('./logo.png');
const logoImage = await pdfDoc.embedPng(logoBytes);
// Scale the logo so its height is exactly 60 points
const logoScale = 60 / logoImage.height;
const logoWidth = logoImage.width * logoScale;
// Draw the logo in the left side of the header
page.drawImage(logoImage, {
x: 30,
y: 712,
width: logoWidth,
height: 60,
});
What does this code do?
- page.drawRectangle() draws a filled rectangle. The y-coordinate of 692 places it near the top (remember, 0 is at the bottom). The rgb() function takes values from 0 to 1, so rgb(0.05, 0.2, 0.4) is a dark navy blue.
- fs.readFileSync('./logo.png') reads the raw bytes of your logo file. pdfDoc.embedPng() embeds those bytes into the PDF so the image can be drawn on any page.
- The scaling math divides 60 by the image's actual height to get a multiplier. This ensures the logo is always exactly 60 points tall, regardless of the original image size.
- Save create-template.js and run it again:
node create-template.js
- Open invoice-template.pdf in your PDF reader. You should see a dark blue bar across the top of the page with your logo image inside it.
Don't see the header or logo?
- If you see an error about reading logo.png, make sure the file is named exactly logo.png (lowercase) and sits in the same folder as your script.
- If you get an error about PNG format, your image file might not be a true PNG. Try a different PNG image or re-save it as PNG using any image editor.
- If the PDF still shows a blank page, make sure your new code is placed above the // --- SAVE --- comment, not below it.
- help me fix my PDF header or logo embedding issue
Now add the business name and tagline text to complete the header.
- In create-template.js, add this code directly below the page.drawImage() block you just wrote (still above // --- SAVE ---):
// Draw the business name in white, positioned to the right of the logo
page.drawText('ACME SOLUTIONS', {
x: 30 + logoWidth + 15,
y: 740,
size: 22,
font,
color: rgb(1, 1, 1),
});
// Draw the tagline in a lighter shade below the business name
page.drawText('Professional Services Invoice', {
x: 30 + logoWidth + 15,
y: 718,
size: 11,
font,
color: rgb(0.8, 0.85, 0.9),
});
What does this code do?
- page.drawText() renders a string at exact coordinates on the page. The x position uses 30 + logoWidth + 15 to place the text 15 points to the right of wherever the logo ends. This keeps the layout dynamic regardless of logo width.
- rgb(1, 1, 1) is pure white for the business name. rgb(0.8, 0.85, 0.9) is a softer blue-gray for the tagline, creating a visual hierarchy.
- Save create-template.js and run it again:
node create-template.js
- Open invoice-template.pdf. You should now see the dark blue header bar with your logo on the left, "ACME SOLUTIONS" in large white text, and "Professional Services Invoice" in smaller light text below it.
Text not showing up?
- Make sure the font property is included in each drawText() call. Without it, pdf-lib does not know which font to render with.
- If you see text but it's invisible against the header, double-check that color: rgb(1, 1, 1) is set for the business name. A missing color defaults to black, which blends into the dark background.
- help me figure out why my drawText isn't rendering in the PDF
Verify the header renders correctly
Your header is complete. Take a moment to confirm everything looks right before moving on to form fields.
- Open invoice-template.pdf one more time and confirm you see all three layers: the dark blue background bar, your logo, and both text lines.
Why coordinates instead of a layout engine?
PDFs don't have CSS or flexbox. Every element is placed at an exact (x, y) coordinate in points. This gives you pixel-perfect control over the layout, which is exactly what you need for something like an invoice where spacing and alignment matter.
The tradeoff is that you manage positioning yourself. You'll get comfortable with this system as you add form fields in the next step.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete create-template.js file as it should look at the end of this step:
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import fs from 'node:fs';
async function createInvoiceTemplate() {
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([612, 792]);
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
// --- CUSTOM HEADER ---
page.drawRectangle({
x: 0,
y: 692,
width: 612,
height: 100,
color: rgb(0.05, 0.2, 0.4),
});
const logoBytes = fs.readFileSync('./logo.png');
const logoImage = await pdfDoc.embedPng(logoBytes);
const logoScale = 60 / logoImage.height;
const logoWidth = logoImage.width * logoScale;
page.drawImage(logoImage, {
x: 30,
y: 712,
width: logoWidth,
height: 60,
});
page.drawText('ACME SOLUTIONS', {
x: 30 + logoWidth + 15,
y: 740,
size: 22,
font,
color: rgb(1, 1, 1),
});
page.drawText('Professional Services Invoice', {
x: 30 + logoWidth + 15,
y: 718,
size: 11,
font,
color: rgb(0.8, 0.85, 0.9),
});
// --- SAVE ---
const pdfBytes = await pdfDoc.save();
fs.writeFileSync('./invoice-template.pdf', pdfBytes);
console.log('Invoice template created: invoice-template.pdf');
}
createInvoiceTemplate();
Your invoice now has a professional branded header. Next up, you'll add interactive form fields so the PDF can accept typed input for client details and invoice information.
Add Fillable Form Fields
Your invoice template has a branded header, but right now it is a static image. A real invoice needs fields where someone can enter data like the invoice number, client name, and due date.
PDF forms use a system called AcroForm to embed interactive fields directly inside the document. In this step, you will use pdf-lib's form API to add text inputs, a dropdown menu, and a checkbox to your template.
In this step, get ready to:
- Add invoice detail fields (number, date, due date) to the right side of the page.
- Add client information fields (name, email, address) to the left side.
- Add a payment method dropdown and a terms checkbox.
Add invoice detail fields
Every form field in a PDF has two parts: a visible label drawn as static text, and an interactive widget that accepts user input. You will draw the labels with page.drawText() and create the input widgets with form.createTextField().
- In create-template.js, find the // --- SAVE --- comment near the bottom of the file.
- Add the following code directly above that comment to create the invoice detail fields on the right side of the page:
// --- FORM FIELDS ---
const form = pdfDoc.getForm();
// Invoice detail labels and text fields (right side)
page.drawText('Invoice #:', { x: 400, y: 660, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
const invoiceNumField = form.createTextField('invoice.number');
invoiceNumField.addToPage(page, { x: 470, y: 655, width: 110, height: 18 });
page.drawText('Date:', { x: 400, y: 635, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
const dateField = form.createTextField('invoice.date');
dateField.addToPage(page, { x: 470, y: 630, width: 110, height: 18 });
page.drawText('Due Date:', { x: 400, y: 610, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
const dueDateField = form.createTextField('invoice.dueDate');
dueDateField.addToPage(page, { x: 470, y: 605, width: 110, height: 18 });
What does this code do?
First, you grab the document's form object with pdfDoc.getForm(). This is the AcroForm container that holds all interactive fields.
For each field, the pattern is the same: draw a static label at one position, then create a named text field and place its input widget slightly to the right. The addToPage() call defines the exact pixel coordinates and size of each clickable input box.
You won't see a change yet. The next chunk adds the client fields, and then you will run the script to verify both sides together.
Add client information fields
The left side of the invoice needs a "Bill To" section where the recipient's details go. These fields mirror the invoice detail fields you just added, but positioned on the opposite side of the page.
- In create-template.js, add the following code directly below the dueDateField.addToPage(...) line you just added:
// Client info fields (left side)
page.drawText('Bill To:', { x: 30, y: 660, size: 11, font, color: rgb(0.2, 0.2, 0.2) });
const clientNameField = form.createTextField('client.name');
clientNameField.addToPage(page, { x: 30, y: 630, width: 200, height: 18 });
const clientEmailField = form.createTextField('client.email');
clientEmailField.addToPage(page, { x: 30, y: 605, width: 200, height: 18 });
const clientAddressField = form.createTextField('client.address');
clientAddressField.addToPage(page, { x: 30, y: 580, width: 200, height: 18 });
What does this code do?
The "Bill To:" label is drawn at the same y-position (660) as the invoice fields on the right, creating a balanced two-column layout. Each client field is 200 points wide, giving plenty of room for names and addresses.
Notice the field names use dot notation like client.name. This naming convention groups related fields together, making them easier to reference later when you fill the form programmatically.
- Save create-template.js.
- Run the script to regenerate the PDF by running:
node create-template.js
- Open invoice-template.pdf in any PDF reader. You should see text input fields on both the left (client) and right (invoice details) sides below the header.
Don't see any form fields?
Make sure you added the new code above the // --- SAVE --- comment, not below it. Code after the save runs too late to be included in the output PDF.
If the fields appear but are not clickable, try opening the PDF in a different reader. Some lightweight viewers do not support AcroForm fields.
help me debug why my PDF form fields are not showing up
Add a payment method dropdown and terms checkbox
Text fields handle free-form input, but some invoice fields need constrained choices. A dropdown limits payment methods to a fixed list, and a checkbox captures a simple yes/no agreement.
- In create-template.js, add the following code below the clientAddressField.addToPage(...) line (still above the // --- SAVE --- comment):
// --- PAYMENT METHOD DROPDOWN ---
const paymentY = 370;
page.drawText('Payment Method:', { x: 30, y: paymentY, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
const paymentDropdown = form.createDropdown('payment.method');
paymentDropdown.addOptions(['Bank Transfer', 'Credit Card', 'PayPal', 'Check']);
paymentDropdown.addToPage(page, { x: 140, y: paymentY - 5, width: 150, height: 18 });
// --- TERMS CHECKBOX ---
const termsY = paymentY - 40;
const termsCheckbox = form.createCheckBox('terms.agreed');
termsCheckbox.addToPage(page, { x: 30, y: termsY - 2, width: 14, height: 14 });
page.drawText('I agree to the payment terms and conditions', {
x: 50,
y: termsY,
size: 9,
font,
color: rgb(0.3, 0.3, 0.3),
});
What does this code do?
The dropdown is created with form.createDropdown('payment.method') and populated with four options via addOptions(). When someone opens the PDF, they will see a clickable dropdown list with these four choices.
The checkbox is created with form.createCheckBox('terms.agreed'). It renders as a small 14x14 point square. The label text is drawn 20 points to its right so it reads naturally as a single interactive element.
- Save create-template.js.
- Run the script again:
node create-template.js
- Open invoice-template.pdf in your PDF reader. You should see a dropdown menu labeled "Payment Method" and a small checkbox with terms text next to it.
- Click the dropdown to confirm you can select from the four payment options.
- Click the checkbox to confirm it toggles on and off.
Dropdown or checkbox not responding?
Some PDF viewers (especially browser-based ones) have limited form support. Try opening the PDF in Adobe Acrobat Reader or Preview (macOS) for full interactivity.
If the dropdown shows no options, make sure paymentDropdown.addOptions([...]) appears before paymentDropdown.addToPage(...). The options must be set before the widget is placed.
help me fix my PDF dropdown or checkbox
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import fs from 'node:fs';
async function createInvoiceTemplate() {
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([612, 792]);
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
// --- CUSTOM HEADER ---
page.drawRectangle({
x: 0,
y: 692,
width: 612,
height: 100,
color: rgb(0.05, 0.2, 0.4),
});
const logoBytes = fs.readFileSync('./logo.png');
const logoImage = await pdfDoc.embedPng(logoBytes);
const logoScale = 60 / logoImage.height;
const logoWidth = logoImage.width * logoScale;
page.drawImage(logoImage, {
x: 30,
y: 712,
width: logoWidth,
height: 60,
});
page.drawText('ACME SOLUTIONS', {
x: 30 + logoWidth + 15,
y: 740,
size: 22,
font,
color: rgb(1, 1, 1),
});
page.drawText('Professional Services Invoice', {
x: 30 + logoWidth + 15,
y: 718,
size: 11,
font,
color: rgb(0.8, 0.85, 0.9),
});
// --- FORM FIELDS ---
const form = pdfDoc.getForm();
page.drawText('Invoice #:', { x: 400, y: 660, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
const invoiceNumField = form.createTextField('invoice.number');
invoiceNumField.addToPage(page, { x: 470, y: 655, width: 110, height: 18 });
page.drawText('Date:', { x: 400, y: 635, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
const dateField = form.createTextField('invoice.date');
dateField.addToPage(page, { x: 470, y: 630, width: 110, height: 18 });
page.drawText('Due Date:', { x: 400, y: 610, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
const dueDateField = form.createTextField('invoice.dueDate');
dueDateField.addToPage(page, { x: 470, y: 605, width: 110, height: 18 });
page.drawText('Bill To:', { x: 30, y: 660, size: 11, font, color: rgb(0.2, 0.2, 0.2) });
const clientNameField = form.createTextField('client.name');
clientNameField.addToPage(page, { x: 30, y: 630, width: 200, height: 18 });
const clientEmailField = form.createTextField('client.email');
clientEmailField.addToPage(page, { x: 30, y: 605, width: 200, height: 18 });
const clientAddressField = form.createTextField('client.address');
clientAddressField.addToPage(page, { x: 30, y: 580, width: 200, height: 18 });
// --- PAYMENT METHOD DROPDOWN ---
const paymentY = 370;
page.drawText('Payment Method:', { x: 30, y: paymentY, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
const paymentDropdown = form.createDropdown('payment.method');
paymentDropdown.addOptions(['Bank Transfer', 'Credit Card', 'PayPal', 'Check']);
paymentDropdown.addToPage(page, { x: 140, y: paymentY - 5, width: 150, height: 18 });
// --- TERMS CHECKBOX ---
const termsY = paymentY - 40;
const termsCheckbox = form.createCheckBox('terms.agreed');
termsCheckbox.addToPage(page, { x: 30, y: termsY - 2, width: 14, height: 14 });
page.drawText('I agree to the payment terms and conditions', {
x: 50,
y: termsY,
size: 9,
font,
color: rgb(0.3, 0.3, 0.3),
});
// --- SAVE ---
const pdfBytes = await pdfDoc.save();
fs.writeFileSync('./invoice-template.pdf', pdfBytes);
console.log('Invoice template created: invoice-template.pdf');
}
createInvoiceTemplate();
Your template now has interactive fields that anyone can fill in by clicking. Next up, you will build a structured line items table so the invoice can hold itemized charges with quantities and rates.
Build the Line Items Table
Your invoice template now has a professional header and fillable fields for invoice details and client information. The next piece is the core of any invoice: the line items table.
A line items table lists the services or products being billed, along with quantities, rates, and amounts. You will build this table using coordinate-based positioning and a loop that generates a row of form fields for each line item.
In this step, get ready to:
- Draw a table header row with column labels.
- Create fillable form fields for 3 line item rows plus a total.
- Save and verify the complete template with all interactive fields.
Draw the table header
The table needs a visible header row so anyone reading the invoice knows what each column represents. You will draw a light gray background rectangle and place column labels on top of it.
- In create-template.js, find the // --- PAYMENT METHOD DROPDOWN --- comment.
- Add the following code directly above that comment:
// --- LINE ITEMS TABLE ---
const tableTop = 540;
page.drawRectangle({
x: 30,
y: tableTop,
width: 552,
height: 20,
color: rgb(0.9, 0.92, 0.95),
});
page.drawText('Description', { x: 35, y: tableTop + 5, size: 9, font, color: rgb(0.2, 0.2, 0.2) });
page.drawText('Qty', { x: 340, y: tableTop + 5, size: 9, font, color: rgb(0.2, 0.2, 0.2) });
page.drawText('Rate', { x: 400, y: tableTop + 5, size: 9, font, color: rgb(0.2, 0.2, 0.2) });
page.drawText('Amount', { x: 490, y: tableTop + 5, size: 9, font, color: rgb(0.2, 0.2, 0.2) });
What does this code do?
The tableTop constant anchors the entire table's vertical position on the page. Every row and field below will be calculated relative to this value.
The light gray rectangle (rgb(0.9, 0.92, 0.95)) acts as a visual header band. The four drawText calls place column labels at specific x-coordinates so they align with the form fields you will add next.
- Save create-template.js.
You won't see a change yet. The next chunk adds the row fields that sit below these column labels.
Create form fields for 3 line item rows
Each line item row needs four fields: a description, quantity, rate, and amount. Instead of creating each field manually, you will use a loop that generates all three rows with consistent spacing.
- In create-template.js, add the following code directly below the four drawText column labels you just added (still above the // --- PAYMENT METHOD DROPDOWN --- comment):
for (let i = 0; i < 3; i++) {
const rowY = tableTop - 25 - i * 28;
const descField = form.createTextField(`item.${i}.description`);
descField.addToPage(page, { x: 30, y: rowY, width: 300, height: 20 });
const qtyField = form.createTextField(`item.${i}.qty`);
qtyField.addToPage(page, { x: 335, y: rowY, width: 55, height: 20 });
const rateField = form.createTextField(`item.${i}.rate`);
rateField.addToPage(page, { x: 395, y: rowY, width: 80, height: 20 });
const amountField = form.createTextField(`item.${i}.amount`);
amountField.addToPage(page, { x: 480, y: rowY, width: 100, height: 20 });
}
// Total
const totalY = tableTop - 25 - 3 * 28 - 10;
page.drawRectangle({
x: 395,
y: totalY - 2,
width: 185,
height: 1,
color: rgb(0.3, 0.3, 0.3),
});
page.drawText('Total:', { x: 430, y: totalY - 20, size: 11, font, color: rgb(0.2, 0.2, 0.2) });
const totalField = form.createTextField('invoice.total');
totalField.addToPage(page, { x: 480, y: totalY - 25, width: 100, height: 20 });
What does this code do?
The loop runs 3 times (i = 0, 1, 2). Each iteration creates four text fields named with the pattern item.0.description, item.0.qty, etc. The rowY calculation spaces each row 28 points apart.
After the loop, totalY is calculated to sit below all three rows. A thin 1-point rectangle acts as a separator line, and the invoice.total field sits beneath it.
Now the payment method dropdown and terms checkbox need to be repositioned below the new table. Their y-coordinates were previously hardcoded, but now they should be calculated relative to totalY.
- In the // --- PAYMENT METHOD DROPDOWN --- section, find this line:
const paymentY = 370;
- Replace 370 with totalY - 70, so the line now looks like this:
const paymentY = totalY - 70;
- Save create-template.js.
Seeing a reference error for totalY?
Make sure your line items table code (the block starting with const tableTop = 540) is placed above the // --- PAYMENT METHOD DROPDOWN --- section. JavaScript reads top-to-bottom, so totalY must be defined before you reference it.
Help me debug my line items table positioning.
Save and verify the complete template
- Run the script to regenerate the PDF by running this command:
node create-template.js
- Open invoice-template.pdf in any PDF reader.
- You should see a light gray table header row with the column labels Description, Qty, Rate, and Amount.
- Click inside the table cells. You should be able to type text into all 12 line item fields (3 rows of 4 fields each) and the total field.
- Scroll down to confirm the payment dropdown and terms checkbox still appear below the table.
Fields overlapping or misaligned?
Check that your const tableTop = 540 line appears before the table header rectangle. If you accidentally placed it after the rectangle draw call, the coordinates will be wrong.
Also verify that const paymentY = totalY - 70 replaced the old hardcoded value. If the payment section still uses 370, it may overlap with your table.
Help me fix my table layout in the invoice PDF.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete create-template.js at this point:
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import fs from 'node:fs';
async function createInvoiceTemplate() {
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([612, 792]);
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
// --- CUSTOM HEADER ---
page.drawRectangle({
x: 0,
y: 692,
width: 612,
height: 100,
color: rgb(0.05, 0.2, 0.4),
});
const logoBytes = fs.readFileSync('./logo.png');
const logoImage = await pdfDoc.embedPng(logoBytes);
const logoScale = 60 / logoImage.height;
const logoWidth = logoImage.width * logoScale;
page.drawImage(logoImage, {
x: 30,
y: 712,
width: logoWidth,
height: 60,
});
page.drawText('ACME SOLUTIONS', {
x: 30 + logoWidth + 15,
y: 740,
size: 22,
font,
color: rgb(1, 1, 1),
});
page.drawText('Professional Services Invoice', {
x: 30 + logoWidth + 15,
y: 718,
size: 11,
font,
color: rgb(0.8, 0.85, 0.9),
});
// --- FORM FIELDS ---
const form = pdfDoc.getForm();
page.drawText('Invoice #:', { x: 400, y: 660, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
const invoiceNumField = form.createTextField('invoice.number');
invoiceNumField.addToPage(page, { x: 470, y: 655, width: 110, height: 18 });
page.drawText('Date:', { x: 400, y: 635, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
const dateField = form.createTextField('invoice.date');
dateField.addToPage(page, { x: 470, y: 630, width: 110, height: 18 });
page.drawText('Due Date:', { x: 400, y: 610, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
const dueDateField = form.createTextField('invoice.dueDate');
dueDateField.addToPage(page, { x: 470, y: 605, width: 110, height: 18 });
page.drawText('Bill To:', { x: 30, y: 660, size: 11, font, color: rgb(0.2, 0.2, 0.2) });
const clientNameField = form.createTextField('client.name');
clientNameField.addToPage(page, { x: 30, y: 630, width: 200, height: 18 });
const clientEmailField = form.createTextField('client.email');
clientEmailField.addToPage(page, { x: 30, y: 605, width: 200, height: 18 });
const clientAddressField = form.createTextField('client.address');
clientAddressField.addToPage(page, { x: 30, y: 580, width: 200, height: 18 });
// --- LINE ITEMS TABLE ---
const tableTop = 540;
page.drawRectangle({
x: 30,
y: tableTop,
width: 552,
height: 20,
color: rgb(0.9, 0.92, 0.95),
});
page.drawText('Description', { x: 35, y: tableTop + 5, size: 9, font, color: rgb(0.2, 0.2, 0.2) });
page.drawText('Qty', { x: 340, y: tableTop + 5, size: 9, font, color: rgb(0.2, 0.2, 0.2) });
page.drawText('Rate', { x: 400, y: tableTop + 5, size: 9, font, color: rgb(0.2, 0.2, 0.2) });
page.drawText('Amount', { x: 490, y: tableTop + 5, size: 9, font, color: rgb(0.2, 0.2, 0.2) });
for (let i = 0; i < 3; i++) {
const rowY = tableTop - 25 - i * 28;
const descField = form.createTextField(`item.${i}.description`);
descField.addToPage(page, { x: 30, y: rowY, width: 300, height: 20 });
const qtyField = form.createTextField(`item.${i}.qty`);
qtyField.addToPage(page, { x: 335, y: rowY, width: 55, height: 20 });
const rateField = form.createTextField(`item.${i}.rate`);
rateField.addToPage(page, { x: 395, y: rowY, width: 80, height: 20 });
const amountField = form.createTextField(`item.${i}.amount`);
amountField.addToPage(page, { x: 480, y: rowY, width: 100, height: 20 });
}
// Total
const totalY = tableTop - 25 - 3 * 28 - 10;
page.drawRectangle({
x: 395,
y: totalY - 2,
width: 185,
height: 1,
color: rgb(0.3, 0.3, 0.3),
});
page.drawText('Total:', { x: 430, y: totalY - 20, size: 11, font, color: rgb(0.2, 0.2, 0.2) });
const totalField = form.createTextField('invoice.total');
totalField.addToPage(page, { x: 480, y: totalY - 25, width: 100, height: 20 });
// --- PAYMENT METHOD DROPDOWN ---
const paymentY = totalY - 70;
page.drawText('Payment Method:', { x: 30, y: paymentY, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
const paymentDropdown = form.createDropdown('payment.method');
paymentDropdown.addOptions(['Bank Transfer', 'Credit Card', 'PayPal', 'Check']);
paymentDropdown.addToPage(page, { x: 140, y: paymentY - 5, width: 150, height: 18 });
// --- TERMS CHECKBOX ---
const termsY = paymentY - 40;
const termsCheckbox = form.createCheckBox('terms.agreed');
termsCheckbox.addToPage(page, { x: 30, y: termsY - 2, width: 14, height: 14 });
page.drawText('I agree to the payment terms and conditions', {
x: 50,
y: termsY,
size: 9,
font,
color: rgb(0.3, 0.3, 0.3),
});
// --- SAVE ---
const pdfBytes = await pdfDoc.save();
fs.writeFileSync('./invoice-template.pdf', pdfBytes);
console.log('Invoice template created: invoice-template.pdf');
}
createInvoiceTemplate();
Your invoice template now has a complete line items table with fillable cells and a total field. Next up, you will write a fill script that loads this template and populates every field with sample data to prove it is fully reusable.
Write the Fill Script
Your template is complete with a branded header, fillable form fields, a line items table, and interactive controls. The real power of a template comes from being able to fill it programmatically.
In this step, you will write a second script that loads your template PDF and populates every field with sample invoice data. This proves the template is reusable for any set of invoice values.
In this step, get ready to:
- Create a fill script that loads and populates the template.
- Fill all form fields with sample client and invoice data.
- Run the script and verify the output PDF has all fields populated.
Create the fill script
The fill script works by loading an existing PDF from disk, accessing its form fields by name, and setting values on each one. This is the same pattern you would use in a real application to generate invoices from a database or API.
- Create a new file called fill-template.js in your pdf-invoice-template folder.
- Add the imports and template-loading logic by pasting this code:
import { PDFDocument } from 'pdf-lib';
import fs from 'node:fs';
async function fillInvoice() {
// Read the template PDF from disk
const templateBytes = fs.readFileSync('./invoice-template.pdf');
const pdfDoc = await PDFDocument.load(templateBytes);
// Access the interactive form
const form = pdfDoc.getForm();
}
What does this code do?
Unlike create-template.js which uses PDFDocument.create() to build from scratch, this script uses PDFDocument.load() to open an existing PDF.
The fs.readFileSync() call reads the template file as raw bytes. Then PDFDocument.load() parses those bytes into a document you can manipulate. Finally, pdfDoc.getForm() gives you access to all the named fields you created in the template.
- Save fill-template.js.
Seeing a file not found error?
Make sure invoice-template.pdf exists in your project folder. If it does not, run node create-template.js first to generate it.
help me debug a file not found error when loading my PDF template
Fill all form fields with sample data
Each form field is accessed by the exact name you gave it in the template. Text fields use .setText(), dropdowns use .select(), and checkboxes use .check().
- In fill-template.js, find the line that says const form = pdfDoc.getForm();, then paste the following code below it (still inside the fillInvoice function):
// Invoice details
form.getTextField('invoice.number').setText('INV-2026-001');
form.getTextField('invoice.date').setText('2026-07-06');
form.getTextField('invoice.dueDate').setText('2026-08-06');
// Client info
form.getTextField('client.name').setText('Jane Smith');
form.getTextField('client.email').setText('jane@example.com');
form.getTextField('client.address').setText('123 Main St, Springfield');
// Line items
form.getTextField('item.0.description').setText('Website Design');
form.getTextField('item.0.qty').setText('1');
form.getTextField('item.0.rate').setText('$2,500.00');
form.getTextField('item.0.amount').setText('$2,500.00');
form.getTextField('item.1.description').setText('SEO Optimization');
form.getTextField('item.1.qty').setText('3');
form.getTextField('item.1.rate').setText('$150.00');
form.getTextField('item.1.amount').setText('$450.00');
form.getTextField('item.2.description').setText('Monthly Hosting');
form.getTextField('item.2.qty').setText('12');
form.getTextField('item.2.rate').setText('$25.00');
form.getTextField('item.2.amount').setText('$300.00');
// Total
form.getTextField('invoice.total').setText('$3,250.00');
// Payment method
form.getDropdown('payment.method').select('Bank Transfer');
// Terms
form.getCheckBox('terms.agreed').check();
What does this code do?
- form.getTextField('invoice.number') retrieves the field by the exact name you gave it in the template. Then .setText() fills it with a value.
- form.getDropdown('payment.method').select('Bank Transfer') picks one of the options you defined when creating the dropdown.
- form.getCheckBox('terms.agreed').check() toggles the checkbox to its checked state.
- Field names must match exactly. If you misspell a name (e.g. invoice.Number instead of invoice.number), pdf-lib throws an error because that field does not exist.
- Save fill-template.js.
Seeing a field name error?
Double check that the field names in your fill script match the ones in create-template.js exactly. Common mistakes include capitalizing a letter or using a hyphen instead of a dot.
help me fix a field name mismatch error in my PDF fill script
Save and verify the filled invoice
The final piece saves the modified PDF to a new file. This keeps your blank template intact so you can fill it again with different data any time.
- Add the save logic below the form-filling code (still inside the fillInvoice function), then close the function and call it by pasting this code:
const pdfBytes = await pdfDoc.save();
fs.writeFileSync('./invoice-filled.pdf', pdfBytes);
console.log('Filled invoice created: invoice-filled.pdf');
}
fillInvoice();
What does this code do?
- pdfDoc.save() serializes the modified document back into PDF bytes.
- fs.writeFileSync('./invoice-filled.pdf', pdfBytes) writes those bytes to a new file. The original template stays untouched.
- fillInvoice() at the bottom kicks off the async function when you run the script.
- Save fill-template.js.
- Run the fill script by running this command:
node fill-template.js
- You should see Filled invoice created: invoice-filled.pdf printed in your terminal.
- Open invoice-filled.pdf in any PDF reader.
- Confirm that all text fields contain sample data, the dropdown shows Bank Transfer selected, and the terms checkbox is checked.
- Click on any field to verify it is still editable. The form is not flattened, so the values can still be changed.
Fields appear empty in the PDF?
Some PDF readers do not render form field values until you click into the field. Try clicking on a field. If the value appears, your script is working correctly.
If values genuinely are not set, make sure you are opening invoice-filled.pdf (the output) and not invoice-template.pdf (the blank template).
help me figure out why my filled PDF fields appear empty
✔️ Awesome, I've got everything!
Great. Double check you have saved fill-template.js and that invoice-filled.pdf exists in your project folder.
ⓧ I'd like to double check the full code
Here is the complete fill-template.js file for comparison:
import { PDFDocument } from 'pdf-lib';
import fs from 'node:fs';
async function fillInvoice() {
const templateBytes = fs.readFileSync('./invoice-template.pdf');
const pdfDoc = await PDFDocument.load(templateBytes);
const form = pdfDoc.getForm();
// Invoice details
form.getTextField('invoice.number').setText('INV-2026-001');
form.getTextField('invoice.date').setText('2026-07-06');
form.getTextField('invoice.dueDate').setText('2026-08-06');
// Client info
form.getTextField('client.name').setText('Jane Smith');
form.getTextField('client.email').setText('jane@example.com');
form.getTextField('client.address').setText('123 Main St, Springfield');
// Line items
form.getTextField('item.0.description').setText('Website Design');
form.getTextField('item.0.qty').setText('1');
form.getTextField('item.0.rate').setText('$2,500.00');
form.getTextField('item.0.amount').setText('$2,500.00');
form.getTextField('item.1.description').setText('SEO Optimization');
form.getTextField('item.1.qty').setText('3');
form.getTextField('item.1.rate').setText('$150.00');
form.getTextField('item.1.amount').setText('$450.00');
form.getTextField('item.2.description').setText('Monthly Hosting');
form.getTextField('item.2.qty').setText('12');
form.getTextField('item.2.rate').setText('$25.00');
form.getTextField('item.2.amount').setText('$300.00');
// Total
form.getTextField('invoice.total').setText('$3,250.00');
// Payment method
form.getDropdown('payment.method').select('Bank Transfer');
// Terms
form.getCheckBox('terms.agreed').check();
const pdfBytes = await pdfDoc.save();
fs.writeFileSync('./invoice-filled.pdf', pdfBytes);
console.log('Filled invoice created: invoice-filled.pdf');
}
fillInvoice();
Secret mission
Add Notes and Flatten the Form
Your invoice template is functional, but real-world invoices often need extra notes for the client. And once an invoice is sent, you don't want anyone editing the values. In this secret mission, you'll add a multiline notes field and then flatten the entire form into a locked, non-editable final PDF.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future use or remove them from your machine.
Resources you used:
- The pdf-invoice-template project folder (scripts, PDFs, and logo.png).
- The node_modules folder inside the project (installed pdf-lib package).
Keep everything running
No action needed. Choose this if you plan to customize the template further or reuse the scripts for real invoices.
- Your project folder contains the generator scripts and output PDFs, ready to customize with your own branding and fields.
- You can re-run node create-template.js and node fill-template.js at any time to regenerate the PDFs.
Pause - I'll come back to this later
Free up disk space by removing the installed packages while keeping your scripts intact.
- Delete the node_modules folder to free up space. You can reinstall later with npm install.
- Navigate to your project folder and run:
macOS
cd ~/Desktop/pdf-invoice-template
rm -rf node_modules
Windows
cd ~\Desktop\pdf-invoice-template
rmdir /s /q node_modules
- Your script files, package.json, and generated PDFs remain untouched.
- When you come back, run npm install in the project folder to restore the dependencies.
Delete - I don't want to use this again
Remove the entire project folder and all generated files.
- Delete the pdf-invoice-template folder and everything inside it by running:
macOS
cd ~/Desktop
rm -rf pdf-invoice-template
Windows
cd ~\Desktop
rmdir /s /q pdf-invoice-template
- This removes all scripts, the node_modules folder, generated PDFs, and your logo.png.
That's a Wrap!
That's a Wrap!
Nice work! You just built a professional invoice PDF template generator from scratch using Node.js and pdf-lib.
You've learned how to:
- Built a PDF document from scratch with a custom branded header featuring an embedded logo image and styled text.
- Created interactive AcroForm fields including text inputs, a dropdown menu, a checkbox, and a line items table with coordinate-based positioning.
- Wrote a fill script that programmatically populates the template with sample data, proving it is reusable for any invoice workflow.
- Secret Mission: Added a multiline notes field and flattened the form into a locked, non-editable final PDF.
Ready to quiz yourself?