How to turn an EJS template into a PDF download in a NestJS app, tested on NestJS 12.2 with Node 22, Puppeteer 25.16 and TypeScript 7, on both the Express and the Fastify adapter. Two versions of the same PdfService: Chrome in your own process, or a call to an HTML-to-PDF API. On the way we hit the NestJS trap that sends a PDF as JSON, and measured the fix.
| Option | Pick it when |
|---|---|
| Puppeteer in a service One Chrome per app, opened in onModuleInit | You run Nest on a VM or a container with room for ~300 MB of Chrome. Free, fast once the browser is warm. |
| fetch() to an API Same service interface, swapped in the module | Serverless, slim containers, or anywhere Chrome won't install. No browser to start or close. |
The obvious controller returns what page.pdf() gave you. On the default Express adapter, Nest passes any object, a Buffer included, to res.json(). We measured it on the invoice below:
| Controller returns | What the client gets |
|---|---|
return buffer (Express adapter) | 88,466 bytes of {"type":"Buffer","data":[37,80,…]}Content-Type: application/json; the real PDF is 25,514 bytes |
Same, plus @Header('Content-Type', 'application/pdf') | The same JSON, now labelled application/pdfThe worst case: the browser downloads a "PDF" that won't open |
return buffer (Fastify adapter) | A valid PDF but as application/octet-stream, with no file name |
return new StreamableFile(buffer, {…}) | A valid PDF on both adapters application/pdf + Content-Disposition |
So return a StreamableFile. It also keeps interceptors and exception filters working, which injecting @Res() and calling res.send() yourself would switch off.
views/invoice.ejs: a flexbox header with a logo, a € price list and a ✓. <%= %> escapes HTML, so a customer called Jane <Doe> Ltd prints as text instead of injecting markup into the browser that prints it.
<!doctype html>
<html><head><meta charset="utf-8"><title>Invoice <%= number %></title><style>
body { font-family: sans-serif; margin: 0; }
.top { display: flex; justify-content: space-between; align-items: center;
border-bottom: 3px solid #2563eb; padding-bottom: 12px; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
td, th { padding: 8px; border-bottom: 1px solid #ddd; text-align: left; }
th { background: #eff6ff; }
.paid { color: #15803d; font-weight: bold; }
</style></head><body>
<div class="top"><img src="<%= logo %>" width="160" height="48" alt="ACME">
<h1>Invoice <%= number %></h1></div>
<p>Billed to: <%= customer %></p>
<table><tr><th>Item</th><th>Price</th></tr>
<% items.forEach(function (it) { %><tr><td><%= it.name %></td><td>€<%= it.price.toFixed(2) %></td></tr><% }) %>
</table>
<p class="paid">✓ Paid</p>
</body></html>
src/invoice.data.ts renders it. The logo is inlined as a data URI because the HTML is printed from about:blank (Puppeteer's setContent) or on another server (the API), where /logo.png points nowhere: Chrome would print a 14×16 broken-image icon instead.
import { readFile } from 'node:fs/promises';
import ejs from 'ejs';
// Inline the logo: the HTML is printed from about:blank (or on another
// server), where a relative src="/logo.png" points nowhere.
const logo = 'data:image/png;base64,' +
(await readFile('public/logo.png')).toString('base64');
export function invoiceHtml(number: string): Promise<string> {
return ejs.renderFile('views/invoice.ejs', {
number, logo, customer: 'Jane <Doe> Ltd',
items: [{ name: 'Hosting', price: 19 }, { name: 'Support', price: 49.5 }],
});
}
This needs npm i ejs and npm i -D @types/ejs, and an ESM project ("type": "module", "module": "nodenext", with experimentalDecorators and emitDecoratorMetadata on), because the file uses top-level await.
src/pdf.service.ts. One browser, launched when the module starts and closed when it stops, and at most 4 pages printing at once so a burst of requests can't exhaust memory:
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import puppeteer, { Browser } from 'puppeteer';
@Injectable()
export class PdfService implements OnModuleInit, OnModuleDestroy {
private browser: Browser;
private free = 4; // max pages printing at once
private waiting: (() => void)[] = [];
async onModuleInit() {
// One Chrome for the whole app. --no-sandbox: needed on Ubuntu 23.10+
// and in most containers; drop it if your Chrome starts without it.
this.browser = await puppeteer.launch({ args: ['--no-sandbox'] });
}
async onModuleDestroy() {
await this.browser?.close();
}
async htmlToPdf(html: string): Promise<Buffer> {
if (this.free === 0) await new Promise<void>(r => this.waiting.push(r));
else this.free--;
const page = await this.browser.newPage();
try {
await page.setContent(html, { waitUntil: 'load' });
const pdf = await page.pdf({ format: 'A4', printBackground: true,
margin: { top: '15mm', bottom: '15mm', left: '15mm', right: '15mm' } });
return Buffer.from(pdf); // page.pdf() returns a Uint8Array
} finally {
await page.close();
const next = this.waiting.shift();
if (next) next(); else this.free++;
}
}
}
src/invoice.controller.ts:
import { Controller, Get, Param, StreamableFile } from '@nestjs/common';
import { PdfService } from './pdf.service.js';
import { invoiceHtml } from './invoice.data.js';
@Controller('invoices')
export class InvoiceController {
constructor(private readonly pdf: PdfService) {}
@Get(':number/pdf')
async download(@Param('number') number: string) {
const buf = await this.pdf.htmlToPdf(await invoiceHtml(number));
// StreamableFile, not the bare Buffer: Nest turns a returned
// Buffer or Uint8Array into JSON.
return new StreamableFile(buf, {
type: 'application/pdf',
disposition: `attachment; filename="invoice-${number}.pdf"`,
});
}
}
src/app.module.ts and src/main.ts:
import { Module } from '@nestjs/common';
import { InvoiceController } from './invoice.controller.js';
import { PdfService } from './pdf.service.js';
@Module({ controllers: [InvoiceController], providers: [PdfService] })
export class AppModule {}
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // runs onModuleDestroy on SIGTERM
await app.listen(Number(process.env.PORT ?? 3010));
Tested with npx tsc && node dist/main.js: GET /invoices/42/pdf returned a one-page A4 PDF with the 160×48 logo, € prices, the ✓ and the escaped customer name. 20 parallel requests all succeeded in 1.26 s through the shared browser. enableShutdownHooks() makes Nest call onModuleDestroy on SIGTERM; after stopping the app, none of its Chrome processes were left running.
Because the controller only depends on PdfService, you can swap the implementation in the module and leave the controller untouched. src/api-pdf.service.ts:
import { Injectable } from '@nestjs/common';
@Injectable()
export class ApiPdfService {
async htmlToPdf(html: string): Promise<Buffer> {
const r = await fetch('https://shotmint.creasework.com/v1/pdf', {
method: 'POST',
headers: { 'X-API-Key': process.env.SHOTMINT_KEY ?? '',
'Content-Type': 'application/json' },
body: JSON.stringify({ html, format: 'A4', margin: '15mm' }),
signal: AbortSignal.timeout(60_000),
});
if (!r.ok) throw new Error(`PDF failed: ${r.status} ${await r.text()}`);
return Buffer.from(await r.arrayBuffer());
}
}
And in src/app.module.ts:
import { Module } from '@nestjs/common';
import { InvoiceController } from './invoice.controller.js';
import { PdfService } from './pdf.service.js';
import { ApiPdfService } from './api-pdf.service.js';
@Module({
controllers: [InvoiceController],
// The controller still asks for PdfService; Nest hands it the API version.
providers: [{ provide: PdfService, useClass: ApiPdfService }],
})
export class AppModule {}
Tested with SHOTMINT_KEY=… node dist/main.js: the same invoice (logo, €, ✓) in 0.1 s per request after the first (0.24 s) from a server in the same data centre, so add your own network round trip; 20 parallel requests all returned 200 in 1.14 s. It uses Node's built-in fetch, so nothing else to install, and Puppeteer can come out of your package.json. print_background is on by default; page_numbers, header_template and landscape are in the API reference. Get a key with one click on the pricing page (25 free renders a month) or with curl -X POST https://shotmint.creasework.com/v1/keys/free.
Keep Puppeteer if your Nest app already runs on a machine with RAM to spare and you can install Chrome in its image. Use the API provider on serverless platforms, in slim containers, or if you'd rather not manage a browser's lifecycle. Either way, return a StreamableFile. To see how your HTML prints in Chrome before writing code, paste it into the free HTML to PDF converter. Related: HTML to PDF in Express, Node.js libraries compared, Puppeteer PDF options, page numbers.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons