HTML to PDF in Node.js
HTML to PDF in Node.js with native fetch. Save to a file, stream from Express or Next.js, and compare it with self-hosted Puppeteer.
html-pdf.com renders your HTML in headless Chromium and returns the PDF. Flexbox, grid, web fonts and JavaScript render as they do in Chrome.
You don't need to bundle Chromium. On Node 18+ there are no dependencies either, because fetch is built in.
Quick answer. Post your HTML to the API and write the response bytes to a file:
const res = await fetch("https://html-pdf.com/v1/pdf", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.HTML_PDF_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ html: "<h1>Hello</h1>" }),
});
await (await import("node:fs/promises")).writeFile("out.pdf", Buffer.from(await res.arrayBuffer()));
Get an API key
This step gives you a key to send with every request. One call creates the account.
curl -X POST https://html-pdf.com/v1/signup \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com"}'
export HTML_PDF_KEY=hp_live_...
Open the link we email you to confirm your address. That adds 100 free credits with no card, and 1 credit = 1 PDF page. You can also get a key at https://html-pdf.com/login.
Convert HTML to PDF and save it
This script turns an HTML string into a PDF file and prints how many credits you have left.
// pdf.mjs (run with: node pdf.mjs)
import { writeFile } from "node:fs/promises";
const res = await fetch("https://html-pdf.com/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HTML_PDF_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html: "<h1>Invoice #042</h1><p>Total: $120.00</p>",
format: "A4",
margin: "15mm",
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${res.status} ${error.code}: ${error.message} (${error.hint ?? ""})`);
}
await writeFile("invoice.pdf", Buffer.from(await res.arrayBuffer()));
console.log("pages:", res.headers.get("x-pages"), "credits left:", res.headers.get("x-credits-remaining"));
Options: page numbers, landscape, URL mode
These options cover the most common layout needs.
const body = {
html,
format: "Letter", // A3, A4, A5, A6, Letter, Legal, Tabloid
landscape: true,
margin: { top: "15mm", right: "15mm", bottom: "20mm", left: "15mm" },
footer_html:
'<div style="font-size:9px;width:100%;text-align:center">' +
'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
max_pages: 20, // fail (no charge) if the PDF would be longer
};
To convert a public web page, send { url: "https://example.com", media: "screen", wait_delay: 2 } instead of html. Use wait_for_expression (for example "window.chartsReady === true") to wait for client-side rendering.
For a download link instead of bytes, add response: "url". The JSON response then has url (valid 24 hours), pages and credits_remaining.
Handle errors
This section tells you what each error means and what to do. Errors are JSON shaped like { error: { code, message, hint } }, and failed renders cost no credits.
402 insufficient_credits: top up, or callPOST https://html-pdf.com/v1/checkoutwith{ "usd": 10 }to get a Stripe payment link.422 max_pages_exceeded/render_failed: fix the input. You weren't charged.429/503: retry with backoff. For429, wait for the time inRetry-After.
Stream the PDF from an Express route
This route sends the PDF straight to the visitor's browser.
import express from "express";
import { Readable } from "node:stream";
const app = express();
app.get("/invoices/:id.pdf", async (req, res, next) => {
try {
const html = `<h1>Invoice ${req.params.id}</h1>`; // render your template here
const r = await fetch("https://html-pdf.com/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HTML_PDF_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, format: "A4", margin: "15mm" }),
});
if (!r.ok) return res.status(r.status).json(await r.json());
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="invoice-${req.params.id}.pdf"`);
Readable.fromWeb(r.body).pipe(res);
} catch (err) {
next(err);
}
});
app.listen(3000);
In production, escape user data in your template. Readable.fromWeb sends bytes to the client as they arrive, without buffering the whole PDF.
Next.js route handler (App Router)
This handler returns a PDF download from a Next.js API route.
// app/api/invoice/[id]/route.ts
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const r = await fetch("https://html-pdf.com/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HTML_PDF_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html: `<h1>Invoice ${id}</h1>`, format: "A4", margin: "15mm" }),
cache: "no-store",
});
if (!r.ok) return Response.json(await r.json(), { status: r.status });
return new Response(r.body, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="invoice-${id}.pdf"`,
},
});
}
This works on the Node.js and Edge runtimes, because it only uses fetch. Keep HTML_PDF_KEY server-side and never expose it with a NEXT_PUBLIC_ prefix.
Upload local images and CSS
This section shows how to include files that aren't on the public web. Reference assets by absolute https:// URL or data: URI, or send them as multipart form data.
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("index.html", new Blob([await readFile("index.html")], { type: "text/html" }), "index.html");
form.append("logo.png", new Blob([await readFile("logo.png")], { type: "image/png" }), "logo.png");
form.append("options", JSON.stringify({ format: "A4" }));
const res = await fetch("https://html-pdf.com/v1/pdf", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.HTML_PDF_KEY}` },
body: form,
});
API vs self-hosted Puppeteer
This section helps you decide whether to run Chrome yourself. Puppeteer is a solid, free choice and gives you full control, but running it yourself means taking on the following.
- Chromium in your deployment. Puppeteer downloads a full Chrome build. Docker images need extra system libraries and fonts, and serverless platforms often need a slimmed-down Chromium package to fit size limits.
- Cold starts. Launching a browser takes time. On serverless, every cold start pays that cost before the first PDF.
- Memory. Each browser and tab needs a lot of RAM. Concurrent renders on small instances can crash or slow down, so you end up adding pools, queues and timeouts.
- Maintenance. Chromium security updates, zombie processes and font differences between your laptop and production are now your job.
With the API, your Node process sends JSON and gets a PDF back. You pay per page ($0.01 to $0.005 per page, prepaid credits that never expire).
Keep Puppeteer if documents must stay on your infrastructure, you render internal-only pages, or you already run browsers at scale.
Full reference: API docs · MCP server
FAQ
How do I convert HTML to PDF in Node.js?
Send POST https://html-pdf.com/v1/pdf with {"html": "..."} and an Authorization: Bearer header using the built-in fetch on Node 18+. Write the response body to a file or stream it to the client.
Can I generate PDFs in Node.js without Puppeteer?
Yes. The API runs headless Chromium for you, so you get the same rendering without bundling Chrome, managing memory or paying cold-start costs.
Does it work on Vercel or the Next.js Edge runtime?
Yes. The route handler only uses fetch, so it runs on both the Node.js and Edge runtimes. Keep HTML_PDF_KEY server-side.
How much does it cost?
1 credit is 1 PDF page, and you get 100 free credits once with no card. Packs range from $10 for 1,000 credits to $250 for 50,000, purchased credits never expire, and failed renders cost nothing.
