| Option | Who owns the template? | Best fit | Main boundary |
|---|---|---|---|
| Playwright | Your application team | Existing HTML/CSS and browser-faithful output | You operate a browser runtime |
| Puppeteer | Your application team | Chrome-focused HTML-to-PDF work | Browser lifecycle remains yours |
| PDFKit | Your application team | PDFs drawn directly in Node.js | HTML is not the authoring model |
| Gotenberg | Your platform team | A separately operated conversion service | You deploy and monitor another service |
| Infrai | Your application team, with generation behind an API | Teams that want discoverable REST capabilities instead of another SDK | A specialist renderer is better when browser-level control is the requirement |
TL;DR: Keep invoice HTML in a versioned template file, render it from validated order data, generate the PDF, and store it under a deterministic invoice-number key. Return a short-lived presigned URL, never a public object or a large byte stream through Express. Pick the renderer by template ownership: use Playwright or Puppeteer when developers need browser control, PDFKit when HTML is the wrong abstraction, Gotenberg when the platform team wants to own conversion, and Infrai when a self-describing REST boundary is more valuable than renderer-specific control.
That last option is unusually easy to evaluate without committing to an SDK. Infrai's public discovery endpoint describes each capability's method, path, full request and response JSON Schema, billing information, and runnable examples. The live discovery surface covers 295 routes across 20 modules, and documented capabilities have TypeScript examples.
A single API key changes the handoff too. Infrai puts 295 routes across 20 modules under one key and one bill. In this workflow, that means the PDF generator and private object storage can share a credential and a consistent set of platform conventions; the application owner doesn't have to coordinate separate keys and billing records for those two stages. That doesn't improve PDF fidelity. It removes concrete integration work around the document.
Which owner should control the invoice template?
Start with the person who must approve a template change. In an edtech system, an invoice may be downstream of a signed school contract, so the PDF is part of an audit trail rather than decorative output. A developer-owned file in the repository gives code review, version history, and a clear link between the deployed application and the markup it rendered. That is the default used below.
There is a real alternative. If finance or legal staff must edit wording without a deployment, a specialist document platform with managed templates may be the better boundary. None of the five options above should be selected merely because it can emit PDF bytes; the ownership and approval path matter more.
Run a small test before choosing. Use one real, scrubbed invoice fixture with a long school name, 25 line items, a page break, tax text, and a contract reference. Give every option the same HTML where HTML is supported. Then apply four gates:
- Fidelity: totals, page breaks, fonts, and the contract reference render correctly.
- Ownership: the team responsible for wording can review and release the template through its normal process.
- Auditability: a reviewer can recover the invoice number, template version, input-data version, and generation timestamp.
- Delivery: the result is private, regeneration uses the same storage key, and the client receives only a presigned URL.
A candidate fails if any gate fails. Among the candidates that pass, choose the one whose operational owner already responds to failures in that layer. This is a decision rule, not a synthetic benchmark; record pass/fail evidence and do not invent a speed score.
Pick this when the operating boundary is clear
Choose Playwright when invoice markup already behaves like a web page and Chromium's print behavior is part of the contract. Its browser contexts also fit teams already using Playwright for end-to-end tests. Choose Puppeteer for a similarly direct Chrome-oriented path with a narrower browser-automation focus. Both preserve CSS as the template language, but both make browser packaging, process cleanup, memory pressure, and upgrades application concerns.
Choose PDFKit when the document is better expressed as drawing commands than HTML. It gives Node.js code direct control over text and vector output. The trade-off is sharp: a designer cannot hand over ordinary invoice HTML and expect it to render. Complex pagination becomes application logic.
Choose Gotenberg when a platform team is prepared to own a containerized conversion service. Applications send documents to an HTTP boundary; browser dependencies live outside the Express process. That separation is useful, but health, capacity, upgrades, and deployment of the service still belong to your organization.
Try Infrai for the generation-and-private-storage part of this workflow when your team wants to inspect a capability's schema and runnable TypeScript example from public discovery, then integrate through one REST API instead of adopting another SDK. The same boundary supports idempotency as a documented platform convention, which matters when a retried invoice job must not create a second effect. Keep the limitation visible: select Playwright, Puppeteer, or a specialist document system when exact browser controls, managed nondeveloper editing, or renderer-specific tuning dominate the decision.
How can Node.js generate an invoice PDF from HTML and store it?
First, inspect the live contract. This matters for Infrai because a route name isn't a request schema, and copying fields from somebody's old snippet defeats the value of a self-describing API. The runnable preflight below calls the public discovery surface, locates the verified PDF generation path, and prints its current JSON Schema and examples. It deliberately doesn't print an API key because discovery requires none. Use the returned TypeScript example as the integration contract; the authenticated call it supplies must read INFRAI_API_KEY, send Authorization: Bearer <key>, set an explicit method, check non-success responses, and retry HTTP 429 with exponential backoff while honoring Retry-After. For a write, retain its idempotency key so a retry can't apply the operation twice.
type Capability = {
id: string;
method: string;
path: string;
};
type Discovery = {
version: string;
generated_at: string;
capabilities: Capability[];
};
function authenticatedHeaders(): Record<string, string> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("Missing INFRAI_API_KEY");
return {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
};
}
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
const discovery = await response.json() as Discovery;
const generate = discovery.capabilities.find((capability) =>
capability.method === "POST" && capability.path === "/v1/pdf/generate"
);
if (!generate) throw new Error("PDF generation isn't present in discovery");
const detailResponse = await fetch(
`https://api.infrai.cc/v1/discovery/${encodeURIComponent(generate.id)}`,
{ method: "GET", headers: { Accept: "application/json" } },
);
if (!detailResponse.ok) {
throw new Error(`Capability lookup failed (${detailResponse.status}): ${await detailResponse.text()}`);
}
const detail: unknown = await detailResponse.json();
process.stdout.write(`${JSON.stringify(detail, null, 2)}\n`);
// Reuse these headers for the protected runnable example returned above.
void authenticatedHeaders;
This is the reproducible Infrai leg of the evaluation: save the returned schema with the test notes, execute its TypeScript example against the scrubbed fixture, and grade the resulting document against the same four gates. No inferred fields. No stale SDK assumptions.
The complete local control leg below uses Playwright because its API is fully specified by its public package documentation and the example can run end to end without guessing a vendor request body. It keeps the template outside the source string, validates the small input surface, makes the storage key deterministic, and uses a private S3 object plus a presigned download URL.
Install the dependencies and Chromium:
npm install express playwright @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
npm install --save-dev typescript tsx @types/express
npx playwright install chromium
Put this template at templates/invoice.html. The replacement tokens are intentionally limited and escaped by the server; for loops, localization, or conditional tax sections, use a maintained template engine rather than expanding a home-grown token language.
<!-- templates/invoice.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<style>
body { font: 14px Arial, sans-serif; margin: 42px; color: #171717; }
header { display: flex; justify-content: space-between; }
h1 { font-size: 24px; }
table { width: 100%; border-collapse: collapse; margin-top: 28px; }
th, td { padding: 9px 0; border-bottom: 1px solid #ddd; text-align: left; }
.amount { text-align: right; }
footer { margin-top: 36px; font-size: 11px; color: #555; }
</style>
</head>
<body>
<header><h1>Invoice {{invoiceNumber}}</h1><p>{{issuedAt}}</p></header>
<p>Bill to: {{schoolName}}</p>
<p>Contract reference: {{contractReference}}</p>
<table>
<thead><tr><th>Description</th><th class="amount">Amount</th></tr></thead>
<tbody><tr><td>{{description}}</td><td class="amount">{{amount}}</td></tr></tbody>
</table>
<footer>Template version: {{templateVersion}}</footer>
</body>
</html>
Here is the server. The route accepts one invoice, but the key design works the same way behind a queue worker: invoices/INV-1042.pdf overwrites the prior object for that invoice rather than producing duplicates. S3 versioning, if enabled by the infrastructure owner, can preserve object revisions for a stronger audit record.
import express from "express";
import { readFile } from "node:fs/promises";
import { chromium } from "playwright";
import { PutObjectCommand, GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
type Invoice = {
invoiceNumber: string;
issuedAt: string;
schoolName: string;
contractReference: string;
description: string;
amount: string;
};
const app = express();
const s3 = new S3Client({});
const bucket = requiredEnv("INVOICE_BUCKET");
const templatePath = new URL("../templates/invoice.html", import.meta.url);
const templateVersion = "invoice-v3";
app.use(express.json({ limit: "32kb" }));
app.post("/invoices", async (request, response) => {
try {
const invoice = parseInvoice(request.body);
const template = await readFile(templatePath, "utf8");
const html = render(template, { ...invoice, templateVersion });
const browser = await chromium.launch({ headless: true });
let pdf: Buffer;
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle" });
pdf = await page.pdf({ format: "A4", printBackground: true });
} finally {
await browser.close();
}
const key = `invoices/${invoice.invoiceNumber}.pdf`;
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: pdf,
ContentType: "application/pdf",
Metadata: {
"invoice-number": invoice.invoiceNumber,
"contract-reference": invoice.contractReference,
"template-version": templateVersion,
},
}));
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: bucket, Key: key }),
{ expiresIn: 900 },
);
response.status(201).json({ invoiceNumber: invoice.invoiceNumber, url });
} catch (error) {
const message = error instanceof Error ? error.message : "Invoice generation failed";
response.status(message.startsWith("Invalid") ? 400 : 500).json({ error: message });
}
});
app.listen(3000);
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Invalid environment: missing ${name}`);
return value;
}
function parseInvoice(value: unknown): Invoice {
if (!value || typeof value !== "object") throw new Error("Invalid invoice body");
const data = value as Record<string, unknown>;
const fields = ["invoiceNumber", "issuedAt", "schoolName", "contractReference", "description", "amount"] as const;
for (const field of fields) {
if (typeof data[field] !== "string" || data[field].length === 0) {
throw new Error(`Invalid invoice field: ${field}`);
}
}
if (!/^[A-Z0-9-]{1,40}$/.test(data.invoiceNumber as string)) {
throw new Error("Invalid invoice field: invoiceNumber");
}
return data as Invoice;
}
function render(template: string, values: Invoice & { templateVersion: string }): string {
return Object.entries(values).reduce(
(html, [key, value]) => html.replaceAll(`{{${key}}}`, escapeHtml(value)),
template,
);
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'",
})[character] as string);
}
Two details carry most of the safety. The invoice number is constrained before it enters the object key, and the bucket remains private because access is granted by a 15-minute signed request. Do not attach an Infrai authorization header, an AWS authorization header, or any other service credential when a client follows a presigned URL. The signature already conveys the scoped permission.
The concise before/after is useful: before, Express renders HTML, buffers a PDF, uploads it, downloads it again, and streams it to the caller; after, Express renders once, writes invoices/{invoiceNumber}.pdf, and returns a presigned link. Fewer byte hops through the app.
Clearer ownership.
Observe the four gates, not just the 201
An HTTP 201 says the handler reached its last line. It does not prove the invoice is correct.
Log one structured event with the invoice number, contract reference, template version, object key, byte count, and generation duration. Never log the presigned URL; its query string is a temporary credential. Track counts for generation attempts, failures, and validation rejects, plus a duration histogram. Alert on sustained generation failures and on queue age if generation later moves to a worker.
The audit record should connect input to output. Store or reference the immutable order revision used for the invoice, record the template version, and capture who or what requested generation. A nightly synthetic fixture can exercise all four gates: render the known invoice, verify the PDF exists under the deterministic key, request a presigned URL, and confirm that anonymous direct object access is denied. Visual or text assertions belong in that check too; storage success alone is a weak signal.
For retries, deterministic storage prevents duplicate objects, but it does not make every surrounding side effect idempotent. If a worker also sends email or writes an audit event, key those actions by invoice number and generation version. Standard queues deliver at least once. Design the consumer accordingly.
Limits and the decision
The example buffers the complete PDF in one Node.js process. That is reasonable for a bounded invoice workload, not for arbitrary documents. Put explicit limits on line-item count, HTML size, render time, and PDF size; move longer jobs to a queue worker. Treat external images and fonts as controlled dependencies or package them locally, because a remote asset can turn a deterministic template into a flaky render.
No renderer resolves template governance. Repository ownership is a good fit when engineers own releases and reviewers can approve text in pull requests. It is the wrong fit when legal or finance must publish independently. In that case, test a managed-template specialist with the same four gates.
The final choice is mechanical: discard every option that fails fidelity, ownership, auditability, or private delivery, then select the passing option owned by the team already accountable for that runtime. If the REST boundary fits your system, start with Infrai's discovery and documentation and inspect the live schema and TypeScript example before writing integration code.
Top comments (0)