Guides · How to generate PDF from HTML in Next.js
How to Generate PDF from HTML in Next.js
Developers searching for how to generate PDF from HTML in Next.js usually land on Puppeteer or html-pdf wrappers. Those work locally — then break on Vercel, cold-start past your function timeout, or ship a 50MB Chromium binary.
This guide shows a copy-paste path that keeps Next.js thin: merge your JSON into an HTML template via API (~200ms), then print or convert to PDF on the client / a small worker.
What you need
- Next.js 14+ (App Router)
- A DocuHook API key (
dh_…) @docuhook/node
npm install @docuhook/node1. Invoice template (HTML)
Keep the layout in a string or file. Tokens use {{path.to.value}}:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Georgia, serif; margin: 40px; color: #1a1f24; }
h1 { font-size: 28px; margin: 0 0 8px; }
.meta { color: #5a6570; font-size: 13px; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
th, td { text-align: left; padding: 8px 0; border-bottom: 1px solid #d8dee4; }
</style>
</head>
<body>
<h1>Invoice {{invoice.number}}</h1>
<p class="meta">{{invoice.date}} · {{customer.name}}</p>
<table>
<thead><tr><th>Item</th><th>Total</th></tr></thead>
<tbody>
<tr><td>{{items.0.name}}</td><td>{{currency}} {{items.0.price}}</td></tr>
</tbody>
</table>
<p><strong>Total {{currency}} {{total}}</strong></p>
</body>
</html>2. Server route that merges HTML
app/api/invoice/route.ts:
import { DocuHookClient } from "@docuhook/node";
import { NextResponse } from "next/server";
import { invoiceTemplate } from "@/lib/invoice-template";
export async function POST(req: Request) {
const data = await req.json();
const client = new DocuHookClient({
apiKey: process.env.DOCUHOOK_API_KEY!,
});
const result = await client.render(data, invoiceTemplate, {
filename: "invoice.pdf",
});
if (!("html" in result)) {
return NextResponse.json({ error: "Unexpected async job" }, { status: 502 });
}
return new NextResponse(result.html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"X-DocuHook-Filename": result.filename,
},
});
}Set DOCUHOOK_API_KEY in .env.local. Never expose it to the browser.
3. Download PDF in the UI
Call your route, then use the browser print dialog or a small client library (html2pdf.js) on the returned HTML:
async function downloadInvoice(order: Record<string, unknown>) {
const res = await fetch("/api/invoice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(order),
});
const html = await res.text();
// open in a hidden iframe + window.print(), or feed html2pdf.js
const w = window.open("", "_blank");
if (w) {
w.document.write(html);
w.document.close();
w.focus();
w.print();
}
}Why not Puppeteer inside Next.js?
puppeteerin a Route Handler — binary size, cold starts, 10–60s timeouts@sparticuz/chromium— still heavy; memory limits on hobby plans- DocuHook merge + print — ~200ms merge; your function only does
fetch
Your Next.js app stays under size limits. Template merge happens off-process.
Async for webhook bursts
If Stripe or a queue dumps many orders at once, use async mode so the HTTP call returns 202 immediately:
const job = await client.render(data, invoiceTemplate, { mode: "async" });
// poll client.jobStatus(job.jobId) / jobResult(job.jobId)The 3-line fix
End-to-end: this is the entire merge call that replaces a Puppeteer HTML→PDF pipeline for the data-binding step.
const dh = new DocuHookClient({ apiKey: process.env.DOCUHOOK_API_KEY });
const { html } = await dh.render(orderJson, invoiceHtml);
// ~200ms — PDF-ready HTML, no Chromium in your Next.js functionTry it in the live sandbox (no signup) or get an API key.