---
title: "Convert HTML to PDF in Node.js With One API Call"
description: "Convert HTML to PDF in Node.js with one API call. Send your markup with format: pdf and get an A4 vector PDF back, fonts embedded and no Chrome to run."
url: "https://html2img.com/articles/html-to-pdf-nodejs/"
section: "Tutorials"
published: "2026-08-26T08:10:34.138Z"
updated: "2026-08-26T08:10:34.138Z"
---

# Convert HTML to PDF in Node.js With One API Call

By Mike Griffiths. https://html2img.com/articles/html-to-pdf-nodejs/

![Convert HTML to PDF in Node.js With One API Call](https://a.storyblok.com/f/320619/1200x630/d5dbb4d7c6/og.png)

You have an invoice, a receipt or a report that already exists as HTML and CSS, and you need a Node.js process to turn it into a PDF. Not a person pressing print, a process: a payment webhook, a queue worker, a cron job, an API route. The usual advice is Puppeteer, which means shipping a copy of Chrome with your application, pinning its version, feeding it fonts and watching its memory. That is a lot of infrastructure to own for a document.

The alternative is one HTTP request. You send the markup with `format: "pdf"` and get back a URL to an A4 vector PDF: selectable text, embedded webfonts, automatic pagination. This guide does that in Node.js with the official `@html2img/client` package, then covers what a real integration needs on top: a template, downloading and attaching the file, fixed-width layouts, error handling and long documents.

## What one request gives you

The [HTML to PDF API](https://html2img.com/html-to-pdf) is the same endpoint as image rendering with one extra parameter. Your HTML is rendered in current Chrome and returned as a real PDF rather than a screenshot wrapped in a PDF shell:

- Text is selectable and searchable, so the document works with copy and paste, search and screen readers.
- Webfonts are embedded. Google Fonts, Adobe Fonts and self-hosted `@font-face` all load server-side and travel with the file.
- Text, borders, shapes and gradients are vector, so they stay sharp at any zoom. Only `<img>` elements and canvases are embedded as pixels.
- Long content paginates automatically across as many A4 pages as it needs.
- Background colours, gradients and images are included by default.
- The response `url` points at a `.pdf` on the CDN, served as `application/pdf`. A PDF costs the same single credit as an image.

Two things to know before you write any markup. Pages are A4 portrait, with no US Letter, landscape or custom sizes, and there are no header and footer slots. And the document is rendered with your normal screen CSS: `@media print` rules are not applied, so everything the PDF needs belongs in your standard styles. The [format parameter docs](https://html2img.com/docs/parameters/format) cover the full behaviour, including which sizing parameters are ignored in PDF mode (`width`, `height`, `dpi`, `fullpage` and `selector`).

## Step 1: install the client and store your key

The official package has no runtime dependencies and is built on the standard `fetch` API, so it runs unchanged in Node.js 18 or newer, Bun, Deno and serverless runtimes.

```
npm install @html2img/client
```

Get an API key from the dashboard and put it in the environment, without a public prefix. This is a server-side client: the key spends credits, so it never goes anywhere near browser code.

```
# .env
HTML2IMG_API_KEY=your-api-key
```

The [authentication docs](https://html2img.com/docs/authentication) cover rotating keys and checking a key with `GET /api/me`, which does not consume a credit.

## Step 2: render your first PDF

The whole conversion is one call. Set `format` to `'pdf'` and the response carries the URL of the finished document.

```
// render-pdf.mjs
import { Html2img } from '@html2img/client';

const client = new Html2img(process.env.HTML2IMG_API_KEY);

const response = await client.html({
  html: `<!doctype html>
<html>
  <body style="font-family: sans-serif; padding: 48px">
    <h1>Invoice #1042</h1>
    <p>Due within 30 days.</p>
  </body>
</html>`,
  format: 'pdf',
});

console.log(response.url);              // https://i.html2img.com/....pdf
console.log(response.creditsRemaining); // credits left on the account
```

Run it with `node --env-file=.env render-pdf.mjs` on Node 20.6 or newer, or load the `.env` file with `dotenv` on older versions. The response is a typed `RenderResponse` with `url`, `id`, `creditsRemaining`, `expiresAt` and `raw`, which holds the full decoded JSON if you want the envelope as the API sent it.

That is a working PDF. Everything from here is about making it a document you would put in front of a customer.

## Step 3: build the document from a template

Keep the HTML in a function that takes your data and returns a complete document. Three rules make the output behave:

1. Write a flowing layout, not a fixed one. The A4 page is roughly 794 CSS pixels wide and content reflows to that width, so use percentages and flex rather than a hard `width: 1200px`.
2. Load fonts with a `<link>` tag or `@import` in the head. They are fetched at render time and embedded in the PDF.
3. Escape every value you interpolate. A customer called `<script>` should not become a script.

```
// invoice.mjs
const escape = (value) =>
  String(value).replace(/[&<>"']/g, (char) => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  })[char]);

const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

export function invoiceHtml(invoice) {
  const rows = invoice.items.map((item) => `
    <tr>
      <td>${escape(item.description)}</td>
      <td class="num">${item.quantity}</td>
      <td class="num">${money.format(item.unitPrice)}</td>
      <td class="num">${money.format(item.quantity * item.unitPrice)}</td>
    </tr>`).join('');

  const total = invoice.items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);

  return `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
  <style>
    body { margin: 0; padding: 56px; font-family: 'Inter', sans-serif; font-size: 14px; color: #0f172a; }
    header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; }
    h1 { margin: 0 0 4px; font-size: 28px; }
    .muted { color: #64748b; }
    table { width: 100%; border-collapse: collapse; margin-top: 24px; }
    th { padding: 0 0 8px; border-bottom: 2px solid #e2e8f0; text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; }
    td { padding: 12px 0; border-bottom: 1px solid #e2e8f0; }
    .num { text-align: right; }
    tr { break-inside: avoid; }
    .total { display: flex; justify-content: flex-end; gap: 32px; margin-top: 24px; font-size: 18px; font-weight: 700; }
  </style>
</head>
<body>
  <header>
    <div>
      <h1>Invoice ${escape(invoice.number)}</h1>
      <div class="muted">Issued ${escape(invoice.issued)} &middot; Due ${escape(invoice.due)}</div>
    </div>
    <div>
      <strong>${escape(invoice.from.name)}</strong><br>
      <span class="muted">${escape(invoice.from.email)}</span>
    </div>
  </header>

  <div><span class="muted">Billed to</span><br><strong>${escape(invoice.to.name)}</strong></div>

  <table>
    <thead>
      <tr><th>Description</th><th class="num">Qty</th><th class="num">Unit</th><th class="num">Amount</th></tr>
    </thead>
    <tbody>${rows}</tbody>
  </table>

  <div class="total"><span>Total due</span><span>${money.format(total)}</span></div>
</body>
</html>`;
}
```

The `tr { break-inside: avoid; }` line is the one people forget. Without it, a line item that lands on a page boundary is cut in half, with its description at the foot of one page and its amount at the top of the next. Automatic pagination fills pages; it has no opinion about what belongs together, and you supply that opinion in CSS. [How to control page breaks in HTML to PDF output](https://html2img.com/articles/html-to-pdf-page-breaks/) covers the full set of fragmentation properties.

Because the renderer is real Chrome, the layout CSS you already use works: grid, flexbox, custom properties, `gap`, modern selectors. [Can I use it in a PDF?](https://html2img.com/articles/html-to-pdf-css-support/) compares that support against the other PDF engines if you are moving a template across from one of them.

Now render it:

```
// send-invoice.mjs
import { Html2img } from '@html2img/client';
import { invoiceHtml } from './invoice.mjs';

const client = new Html2img(process.env.HTML2IMG_API_KEY);

const invoice = {
  number: 'INV-1042',
  issued: '26 August 2026',
  due: '25 September 2026',
  from: { name: 'Northgate Coffee', email: 'billing@northgate.example' },
  to: { name: 'Riverside Bakery' },
  items: [
    { description: 'Wholesale beans, 5kg', quantity: 12, unitPrice: 48 },
    { description: 'Delivery', quantity: 1, unitPrice: 15 },
  ],
};

const { url } = await client.html({ html: invoiceHtml(invoice), format: 'pdf' });

console.log(url);
```

If you want to see what the markup will look like before spending a credit, paste it into the free [HTML to PDF converter](https://html2img.com/tools/html-to-pdf). It runs the same renderer.

## Step 4: download the file, attach it, or keep the URL

The response carries a hosted URL rather than the bytes, which is usually what you want: store it against the order and link to it from the account page. When you need the file itself, for an email attachment or your own storage, fetch it:

```
import { writeFile } from 'node:fs/promises';

const render = await client.html({ html: invoiceHtml(invoice), format: 'pdf' });

const file = await fetch(render.url);
const pdf = Buffer.from(await file.arrayBuffer());

await writeFile(`invoices/${invoice.number}.pdf`, pdf);
```

The same buffer goes straight into a mail attachment. With Nodemailer:

```
await transporter.sendMail({
  to: 'accounts@riverside.example',
  subject: `Invoice ${invoice.number}`,
  text: 'Your invoice is attached.',
  attachments: [
    { filename: `${invoice.number}.pdf`, content: pdf, contentType: 'application/pdf' },
  ],
});
```

One detail to build in: on the free tier, renders expire and `response.expiresAt` tells you when. On paid plans it is `null` and the file is hosted permanently. If a free-tier document needs to outlive that window, download a copy as above.

## Serve it from an API route

In a long-running Node server, build the client once at module scope and reuse it. Return the URL rather than proxying the bytes: the CDN serves the file better than your process will.

```
import express from 'express';
import { Html2img, Html2imgError } from '@html2img/client';
import { invoiceHtml } from './invoice.mjs';

const app = express();
const client = new Html2img(process.env.HTML2IMG_API_KEY);

app.post('/api/invoices/:id/pdf', async (request, response) => {
  const invoice = await loadInvoice(request.params.id);

  try {
    const render = await client.html({ html: invoiceHtml(invoice), format: 'pdf' });
    response.json({ url: render.url });
  } catch (error) {
    if (error instanceof Html2imgError) {
      response.status(error.statusCode ?? 502).json({ error: error.message });
      return;
    }
    throw error;
  }
});

app.listen(3000);
```

The same shape works in a Next.js route handler, a Nuxt server route or a Lambda handler. The [JavaScript integration guide](https://html2img.com/integrations/javascript) has each of those written out.

## Fixed-width layouts: scale the design to the page

Some documents are not flowing text. A certificate, a wide comparison table or a dashboard export is a fixed-width design, and if it is wider than the A4 page a plain PDF render crops it at the right edge. The `scale_to_fit` parameter fixes that: the renderer lays the content out at its natural width, then scales the whole layout down to fit the page. The scaling is vector, so text stays selectable and sharp, and any trailing blank page is trimmed.

As of version 2.0.0, `scale_to_fit` is not on the SDK's option types, so send that one request with plain `fetch`. This is also the whole API without a dependency: one POST, one header.

```
const response = await fetch('https://app.html2img.com/api/html', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json',
    'X-API-Key': process.env.HTML2IMG_API_KEY,
  },
  body: JSON.stringify({
    html: certificateHtml(attendee),
    format: 'pdf',
    scale_to_fit: true,
  }),
  signal: AbortSignal.timeout(35_000),
});

const data = await response.json();

if (!response.ok || !data.success) {
  throw new Error(`Render failed with ${response.status}: ${JSON.stringify(data)}`);
}

console.log(data.url);
```

Leave `scale_to_fit` off for the invoice above. A document written to flow reads better reflowed to the page width at full size than scaled down. The [scale\_to\_fit docs](https://html2img.com/docs/parameters/scale-to-fit) explain the measuring step and when it applies.

## Handle the errors that will actually happen

Every failed request rejects with an `Html2imgError`, and the useful cases have their own subclass. The distinction that matters is between failures that are worth retrying and failures that are not.

```
import {
  Html2img,
  Html2imgError,
  InsufficientCreditsError,
  TimeoutError,
  ValidationError,
} from '@html2img/client';

try {
  const render = await client.html({ html: invoiceHtml(invoice), format: 'pdf' });
  await saveInvoiceUrl(invoice.number, render.url);
} catch (error) {
  if (error instanceof ValidationError) {
    // 400 or 422: the request is wrong, retrying will not help
    console.error('Rejected fields:', error.details);
  } else if (error instanceof InsufficientCreditsError) {
    // 402: nothing will render until the account is topped up
    console.error(`Out of credits (${error.creditsRemaining} left)`);
  } else if (error instanceof TimeoutError) {
    // 504: the render exceeded the 30 second budget, resend with a webhook
  } else if (error instanceof Html2imgError) {
    // 5xx or a connection failure: retry with backoff
    console.error(error.statusCode, error.errorCode, error.payload);
  } else {
    throw error;
  }
}
```

The client deliberately does not retry on its own. A 5xx or a dropped connection is worth a second attempt; a 4xx is not, and a retry loop around a validation error just spends credits on the same bad request. If you want a retry policy, pass your own `fetch` in the constructor options and put the backoff there.

## Long reports: take the render off the request

A synchronous render has a 30 second budget and the client waits 35 by default. An invoice is nowhere near that. A 40-page report with large tables can be, and several serverless platforms cut a function off well before either limit. For those, pass a `webhookUrl`. The API answers immediately with a `processing` status and the render id, then POSTs the finished URL to your endpoint when the document is ready.

```
const response = await client.html({
  html: monthlyReportHtml(report),
  format: 'pdf',
  webhookUrl: 'https://your-app.example.com/hooks/html2img?token=shared-secret',
});

if (response.isProcessing()) {
  await db.renders.insert({ id: response.id, report: report.id, status: 'pending' });
}
```

Your endpoint receives a JSON body with `status`, the `url` on success or an `error` message on failure, and a `log_id` that matches the `id` from the initial response, which is how you tie the callback back to the report. Return a 200 within a few seconds and do the rest asynchronously. Delivery is attempted once and the API sends no custom headers, so put a secret in the URL as above. The [webhook\_url docs](https://html2img.com/docs/parameters/webhook-url) have the exact payloads.

## What you are no longer maintaining

The list of things this integration does not contain is the point of it. There is no Chrome binary in the deploy and no `puppeteer-core` and `@sparticuz/chromium` pair to keep in step every time Chrome ships a major version. There is no font manifest to curate so that a customer's name does not render as hollow rectangles. There is no memory tuning, no crash recovery and no cold-start budget spent booting a browser. [Why HTML to PDF with Puppeteer keeps breaking on serverless](https://html2img.com/articles/puppeteer-html-to-pdf-serverless/) goes through each of those failures in detail if you are currently living with them.

The trade is stated plainly on the product page: the PDF is built for parity with the image renderer, so it looks exactly like the PNG of the same input would. If your project needs US Letter, landscape pages, running headers and footers or print stylesheets, a dedicated PDF service is the better fit, and [The complete guide to generating PDFs from HTML](https://html2img.com/articles/generate-pdf-from-html/) compares every route, including the ones that count against us. For the invoice, receipt, certificate or report that already exists as HTML in your application, the whole job is the one call at the top of this article.

---

Need invoices, receipts or certificates rendered from HTML without running a browser yourself? [Browse the templates gallery](https://html2img.com/templates) or [read the docs](https://html2img.com/docs) to get started.
