DocuHook

Guides · How to generate PDF from HTML in Next.js

How to Generate PDF from HTML in Next.js

2026-10-19 · 8 min read

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/node

1. 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?

  • puppeteer in 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 function

Try it in the live sandbox (no signup) or get an API key.