# 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:

```js
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.

```bash
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.

```js
// 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.

```js
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 call `POST https://html-pdf.com/v1/checkout` with `{ "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. For `429`, wait for the time in `Retry-After`.

## Stream the PDF from an Express route

This route sends the PDF straight to the visitor's browser.

```js
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.

```ts
// 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.

```js
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](https://html-pdf.com/docs) · [MCP server](https://html-pdf.com/mcp)

## 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.
