How to Generate Dynamic PDFs in NestJS with a Headless Browser
Generating a simple, unstyled PDF is one thing. But what if you need to create complex, beautifully styled documents? Learn how to leverage the power of HTML/CSS with headless browsers.
How to Generate Dynamic PDFs in NestJS with a Headless Browser
Generating a simple, unstyled PDF is one thing. But what if you need to create a complex, beautifully styled document like a customer invoice, a weekly analytics report, or a sales brochure? Building these layouts with traditional PDF libraries can be a painful, pixel-pushing nightmare of manual coordinates and complex APIs.
There’s a much better way: leverage the power of the web. You already know how to build and style complex layouts with HTML and CSS. What if you could just render your data into an HTML template and then “print” that web page to a PDF on the server?
This is the core idea behind using a headless browser for PDF generation, and it’s an incredibly powerful pattern. In this post, we’ll walk through how to build a robust PDF generation service in NestJS using a templating engine and Playwright for headless browser control.
The Big Idea: Render HTML, Then “Print” to PDF
The workflow is straightforward and elegant:
- Create a Template: Build your document layout as a standard HTML file using a templating engine like Eta, EJS, or Handlebars. You can style it with CSS, just like any other webpage.
- Render the HTML: In your NestJS service, fetch the dynamic data needed for the document and use your templating engine to render the template into a complete HTML string.
- Launch a Headless Browser: Use a library like Playwright or Puppeteer to launch a lightweight, “headless” instance of a web browser (like Chromium) in the background on your server.
- Generate the PDF: Tell the headless browser to load your HTML string, and then use its built-in “print to PDF” functionality to generate a PDF buffer or file.
- Serve the PDF: Send the generated PDF back to the user as a download.
Step 1: Crafting the PDF Template
First, we create our document using the tools we already know. Let’s imagine we’re creating an invoice.
<% // views/templates/invoice-template.eta %>
<html>
<head>
<style>
/* You can link to an external stylesheet or embed styles directly */
body {
font-family: sans-serif;
}
.invoice-box {
max-width: 800px;
margin: auto;
padding: 30px;
border: 1px solid #eee;
}
.header {
text-align: center;
margin-bottom: 20px;
}
.line-item {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #eee;
padding: 5px 0;
}
.total {
text-align: right;
font-weight: bold;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="invoice-box">
<header>
<h1>Invoice #<%= it.invoiceNumber %></h1>
<p>Billed to: <%= it.customerName %></p>
</header>
<main>
<% it.lineItems.forEach(item => { %>
<div class="line-item">
<span><%= item.description %></span>
<span>$<%= item.price.toFixed(2) %></span>
</div>
<% }) %>
<div class="total">Total: $<%= it.total.toFixed(2) %></div>
</main>
</div>
</body>
</html>
The key here is that you can use all the power of HTML and CSS to create your layout. For more complex designs, using @media print
rules in your CSS can give you fine-grained control over the final PDF output, including page breaks and margins.
Step 2: Setting up the Playwright Service
To interact with the headless browser, we’ll create a dedicated PlaywrightService
. This service will be responsible for the core PDF generation logic.
// In src/common/playwright.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { chromium, Browser, Page } from "playwright";
@Injectable()
export class PlaywrightService implements OnModuleInit, OnModuleDestroy {
private browser: Browser;
async onModuleInit() {
// Launch the browser instance when the module loads
this.browser = await chromium.launch();
}
async onModuleDestroy() {
// Gracefully close the browser when the app shuts down
await this.browser.close();
}
async generatePdfFromHtml(html: string): Promise<Buffer> {
const page: Page = await this.browser.newPage();
// Set the page content to our rendered HTML string
await page.setContent(html, { waitUntil: "networkidle" });
// Generate the PDF
const pdfBuffer = await page.pdf({
format: "Letter",
printBackground: true, // Important for including CSS background colors/images
margin: { top: "20px", bottom: "40px", left: "20px", right: "20px" },
});
await page.close();
return pdfBuffer;
}
}
This service encapsulates all the Playwright logic. It launches a single browser instance and reuses it for efficiency. The generatePdfFromHtml
method is the heart of the operation.
Step 3: The Main Service Orchestration
Now, we can bring it all together in our main business logic service (e.g., a ReportingService
). This service will fetch the data, render the template, and call the PlaywrightService
.
// In src/reporting/reporting.service.ts
import { Injectable } from "@nestjs/common";
import { PlaywrightService } from "../common/playwright.service";
import { InvoicesService } from "../invoices/invoices.service";
import * as eta from "eta";
import * as path from "path";
@Injectable()
export class ReportingService {
constructor(
private readonly playwrightService: PlaywrightService,
private readonly invoicesService: InvoicesService,
) {
// Configure Eta to look for templates in our views directory
eta.configure({ views: path.join(__dirname, "..", "..", "views") });
}
async generateInvoicePdf(invoiceId: string): Promise<Buffer> {
// 1. Fetch dynamic data for the report
const invoiceData = await this.invoicesService.getInvoiceData(invoiceId);
// 2. Render the Eta template into an HTML string
const html = await eta.renderFileAsync(
"/templates/invoice-template.eta",
invoiceData,
);
// 3. Call the Playwright service to generate the PDF
const pdfBuffer = await this.playwrightService.generatePdfFromHtml(html);
return pdfBuffer;
}
}
Step 4: The Controller
Finally, the controller ties it all together. It receives the request, calls the service to get the PDF buffer, and streams it back to the user with the correct headers.
// In src/reporting/reporting.controller.ts
import { Controller, Get, Param, Res } from "@nestjs/common";
import { ReportingService } from "./reporting.service";
import { FastifyReply } from "fastify";
@Controller("reports")
export class ReportingController {
constructor(private readonly reportingService: ReportingService) {}
@Get("invoice/:invoiceId/download")
async downloadInvoice(
@Param("invoiceId") invoiceId: string,
@Res() response: FastifyReply,
) {
const pdfBuffer = await this.reportingService.generateInvoicePdf(invoiceId);
// Set HTTP headers for PDF download
response.header("Content-Type", "application/pdf");
response.header(
"Content-Disposition",
`attachment; filename="invoice-${invoiceId}.pdf"`,
);
// Send the buffer as the response
response.send(pdfBuffer);
}
}
By setting the correct Content-Type
and Content-Disposition
headers, the browser will prompt the user to download the file, creating a seamless user experience.
The Payoff: Power and Simplicity
This pattern is a game-changer for server-side document generation. By using web technologies (HTML/CSS) you’re already an expert in, you can:
- Build Complex Layouts: Create intricate, pixel-perfect designs that would be difficult or impossible with traditional PDF libraries.
- Reuse Components: Use the same templating engine and partials you use for the rest of your application.
- Maintain with Ease: Updating the look of your PDF is as simple as changing a CSS file.
- Create a Robust Service: Encapsulating the browser logic in a dedicated service keeps your code clean, organized, and easy to test.
It’s a modern, powerful, and effective way to handle a common backend task.