js

How to Generate Professional PDF Invoices with HTML, CSS, Puppeteer, and Node.js

Learn how to generate professional PDF invoices with HTML, CSS, Puppeteer, and Node.js using a scalable, reliable pipeline.

How to Generate Professional PDF Invoices with HTML, CSS, Puppeteer, and Node.js

I’ve been here before. You need a professional PDF invoice, fast. Maybe for a client, or for your own records. You start writing code, then hit a wall. The formatting is off. The totals don’t align. The font looks wrong. You’re left with a document that looks… unprofessional. I’ve spent hours, maybe days, in that exact spot. That’s why I built this pipeline. It turns messy data into clean, precise invoices. Let’s build it together.

Most libraries force you to draw a PDF like an etch-a-sketch. You tell it, “put text here, a line there.” It’s tedious and fragile. What if you could use the tools you already know? HTML and CSS. You can style a page exactly how you want. This system does just that. We use HTML as our canvas and a headless browser to print it. The result is a PDF that matches your vision, every single time.

Think about the last invoice you received. What made it look good? Consistent spacing. Clear totals. A professional layout. Now, how do we code that? We start with a solid plan.

First, we define our data. Clarity here prevents errors later. We describe everything: the sender, the client, each item sold, taxes, and dates. TypeScript helps us catch mistakes early.

// This is the blueprint for our invoice.
interface InvoiceData {
  invoiceNumber: string;
  issueDate: Date;
  from: { name: string; address: string };
  to: { name: string; address: string };
  items: Array<{
    description: string;
    quantity: number;
    unitPrice: number;
  }>;
}

Data alone isn’t enough. We must ensure what we receive is valid. A wrong date or a negative price can break the process. We use a validation library to set clear rules.

import { z } from 'zod';

// These rules guard our pipeline.
const InvoiceSchema = z.object({
  invoiceNumber: z.string().min(1),
  issueDate: z.string().datetime(),
  items: z.array(z.object({
    quantity: z.number().positive(),
    unitPrice: z.number().positive(),
  })).min(1)
});

Have you ever tried changing a PDF template across thousands of documents? It’s a nightmare. Our solution keeps the design separate from the logic. We use a simple templating language. It lets us inject data into an HTML structure.

This is our template, a standard HTML file with special placeholders.

<!-- templates/invoice.hbs -->
<html>
<body>
  <h1>Invoice {{invoiceNumber}}</h1>
  <p>Date: {{formatDate issueDate}}</p>
  <div class="from-address">{{from.address}}</div>
  <div class="to-address">{{to.address}}</div>
  <table>
    {{#each items}}
    <tr>
      <td>{{this.description}}</td>
      <td>{{this.quantity}}</td>
      <td>{{formatCurrency this.unitPrice}}</td>
    </tr>
    {{/each}}
  </table>
  <p class="total">Total: {{calculateTotal items}}</p>
</body>
</html>

The template is clean. The placeholders, like {{invoiceNumber}}, will be replaced with real data. Helpers like formatCurrency handle the formatting. This separation is powerful. A designer can adjust the HTML without touching the code.

Now, how do we turn this HTML into a PDF? We use a tool called Puppeteer. It controls a real browser in the background. We give it our finished HTML, and it “prints” it to a PDF file. This method supports modern CSS, custom fonts, and complex layouts.

But starting a browser for every PDF is slow. What if ten requests come in at once? We need to be efficient. We create a pool. It keeps a few browser instances ready to go, saving precious time.

// A simple pool to manage browser instances.
class BrowserPool {
  constructor(maxBrowsers = 5) {
    this.pool = [];
    this.max = maxBrowsers;
  }
  async getBrowser() {
    if (this.pool.length > 0) {
      return this.pool.pop(); // Reuse an existing one.
    }
    // Launch a new browser if the pool is empty.
    return puppeteer.launch({ headless: 'new' });
  }
  async releaseBrowser(browser) {
    if (this.pool.length < this.max) {
      this.pool.push(browser); // Put it back for later.
    } else {
      await browser.close(); // We have enough, close it.
    }
  }
}

We bring all these parts together in a service. This service compiles the template with data, gets a browser from the pool, and generates the PDF buffer.

class PdfService {
  constructor(templateCompiler, browserPool) {
    this.compileTemplate = templateCompiler;
    this.browserPool = browserPool;
  }

  async generateInvoicePdf(invoiceData) {
    // 1. Validate the input data.
    const validData = InvoiceSchema.parse(invoiceData);

    // 2. Compile HTML with the data.
    const htmlContent = this.compileTemplate('invoice', validData);

    // 3. Use a browser from the pool.
    const browser = await this.browserPool.getBrowser();
    try {
      const page = await browser.newPage();
      await page.setContent(htmlContent);
      const pdfBuffer = await page.pdf({ format: 'A4' });
      return pdfBuffer;
    } finally {
      // 4. Always return the browser to the pool.
      await this.browserPool.releaseBrowser(browser);
    }
  }
}

Finally, we expose this as an API endpoint. A simple POST request where you send your invoice JSON and receive a PDF.

app.post('/api/generate-invoice', async (req, res) => {
  try {
    const pdfBuffer = await pdfService.generateInvoicePdf(req.body);
    res.setHeader('Content-Type', 'application/pdf');
    res.setHeader('Content-Disposition', 'attachment; filename="invoice.pdf"');
    res.send(pdfBuffer);
  } catch (error) {
    res.status(400).send({ error: error.message });
  }
});

You can run this service anywhere. I often run it in a Docker container. It keeps the environment consistent, whether it’s on my laptop or a cloud server. The Dockerfile is straightforward: it installs Node, the browser dependencies, and our application code.

This approach solved my problem. It gave me control and reliability. No more wrestling with low-level PDF commands. I define my data, design my template, and get a perfect document. It scales easily because the browser pool manages the heavy lifting. You can add caching for common invoices to make it even faster.

The next time you need to generate documents, think about this pipeline. It uses the web’s own rendering engine, through tools you likely already know. It turns a complex task into a series of simple, reliable steps.

Was this walkthrough helpful? Does it clarify a path for your own document automation? If you’ve faced similar challenges with PDFs, I’d like to hear about it. Share your thoughts in the comments below. If this guide can help others in your network, please consider passing it along. Let’s build better tools, together.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: PDF invoice generation, Puppeteer, Node.js, HTML CSS templates, invoice API



Similar Posts
Blog Image
Master Event-Driven Microservices: Node.js, EventStore, and NATS Streaming Complete Guide

Learn to build scalable event-driven microservices with Node.js, EventStore & NATS. Master event sourcing, CQRS, sagas & distributed systems. Start building now!

Blog Image
Complete Guide to Type-Safe Event-Driven Architecture with TypeScript, EventEmitter2, and Redis

Master TypeScript event-driven architecture with EventEmitter2 & Redis. Learn type-safe event handling, scaling, persistence & monitoring. Complete guide with code examples.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Full-Stack Development

Learn to integrate Next.js with Prisma ORM for type-safe, full-stack React apps. Build database-driven applications with seamless API routes and TypeScript support.

Blog Image
Building Event-Driven Microservices with NestJS: RabbitMQ and MongoDB Complete Guide

Learn to build event-driven microservices with NestJS, RabbitMQ & MongoDB. Master async communication, error handling & monitoring for scalable systems.

Blog Image
How to Integrate Next.js with Prisma: Complete Guide for Type-Safe Full-Stack Development

Learn how to integrate Next.js with Prisma ORM for type-safe full-stack development. Build modern web apps with seamless database connectivity and optimized performance.

Blog Image
Next.js Prisma Integration Guide: Build Type-Safe Full-Stack Apps with Modern Database ORM

Learn how to integrate Next.js with Prisma ORM for type-safe, full-stack applications. Build powerful database-driven apps with seamless TypeScript support.