---
title: "HTML to Image API for JavaScript | Node, TypeScript, Next.js, Nuxt"
description: "Render HTML to PNG, screenshot URLs and build PDFs from Node.js, TypeScript, Bun, Deno, Next.js and Nuxt with the official zero-dependency html2img SDK."
url: "https://html2img.com/integrations/javascript/"
---

# HTML to Image API for JavaScript

The official JavaScript and TypeScript client is built on the standard fetch API with zero runtime dependencies, so it works unchanged in Node.js, Bun, Deno, serverless functions and edge runtimes. It is a server-side client: your API key must never reach the browser.

- **Install:** `npm install @html2img/client`
- **Registry:** [npm](https://www.npmjs.com/package/@html2img/client)
- **Source:** https://github.com/html2img/html2img-js
- **Licence:** MIT
- **Requires:** Node.js 18 or newer, or any runtime with a global fetch

One package covers every JavaScript runtime worth naming. It is built on the
standard `fetch` API with no runtime dependencies, ships dual ESM and CommonJS
builds and its own TypeScript declarations, and runs unchanged in Node.js, Bun,
Deno, serverless functions and edge runtimes.

> **Warning: This is a server-side client**
>
> Your API key spends real credits. Anything you ship to the browser is public,
> including `NEXT_PUBLIC_*`, `VITE_*` and `REACT_APP_*` variables, which are inlined
> into the bundle at build time. Call the API from a route handler, a server action,
> a Nuxt server route or a background job, and let the browser talk to your server
> instead. Every example on this page runs on the server.

## What you can build

- **Open Graph images per route**, generated at build time or on publish and
  served from your own CDN.
- **Invoices, receipts and reports** as PNGs on screen, or
  [converted from HTML to PDF](https://html2img.com/html-to-pdf/) for the copy you email.
- **Screenshots of live URLs** for link previews, directory thumbnails or visual
  regression checks, through the [Screenshot API](https://html2img.com/screenshot-api/).
- **Charts and dashboards as images** for email and Slack, which will not run
  your charting library but will happily show a PNG.
- **Build-time assets** in a static site generator, rendered once during the
  build rather than on every request.

## Requirements

| Requirement | Version |
| --- | --- |
| Node.js | 18 or newer, for the built-in global `fetch` |
| Bun, Deno, edge runtimes | Any version with a global `fetch` |
| TypeScript | Optional; declarations are bundled, no `@types` package |
| API key | Free, from your [dashboard](https://app.html2img.com/register) |

Older Node versions, or any runtime without a global `fetch`, can pass their own
implementation through the `fetch` option.

## Installation

```bash
npm install @html2img/client
# or
pnpm add @html2img/client
# or
yarn add @html2img/client
# or
bun add @html2img/client
```

Put the key in the environment, without a public prefix:

```dotenv
HTML2IMG_API_KEY=your-api-key
```

## Quick start

```js
import { Html2img } from '@html2img/client';

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

const response = await client.html({
  html: '<!doctype html><html><body><h1>Hello from Node</h1></body></html>',
  width: 1200,
  height: 630,
});

console.log(response.url); // https://i.html2img.com/abc123def456.png
```

The API returns a JSON envelope containing the CDN URL of the render, not the
bytes, so you can store the URL and re-serve it from your own infrastructure.

## Module formats and runtimes

### ESM

The package is ESM-first, which is what the examples above use.

```js
import { Html2img, ValidationError } from '@html2img/client';
```

### CommonJS

A CommonJS build ships alongside it, with the same exports:

```js
const { Html2img, ValidationError } = require('@html2img/client');
```

### TypeScript

Declarations are bundled, so every option, response property and error class is
typed with nothing extra to install. The option and response types are exported
if you want to name them:

```ts
import {
  Html2img,
  type HtmlOptions,
  type ScreenshotOptions,
  type Format,
  type RenderResponse,
} from '@html2img/client';

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

const cardOptions: HtmlOptions = {
  html: document,
  width: 1200,
  height: 630,
  dpi: 2,
};

const response: RenderResponse = await client.html(cardOptions);
```

### Bun

Nothing special: Bun provides a global `fetch`.

```ts
import { Html2img } from '@html2img/client';

const client = new Html2img(Bun.env.HTML2IMG_API_KEY!);

await client.html({ html: document, width: 1200, height: 630 });
```

### Deno

Import from npm and read the key from the environment. The script needs
`--allow-net` and `--allow-env`.

```ts
import { Html2img } from 'npm:@html2img/client';

const client = new Html2img(Deno.env.get('HTML2IMG_API_KEY')!);

const response = await client.html({ html: document, width: 1200, height: 630 });

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

### Serverless and edge

The client has no Node built-ins in its dependency graph, so it runs on Vercel
Functions, Netlify Functions, Cloudflare Workers and AWS Lambda without a
polyfill. Construct it once at module scope so the instance is reused across warm
invocations:

```js
import { Html2img } from '@html2img/client';

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

export default async function handler(request) {
  const { title } = await request.json();

  const response = await client.html({
    html: card(title),
    width: 1200,
    height: 630,
  });

  return Response.json({ url: response.url });
}
```

> **Note: Mind the platform timeout**
>
> A synchronous render has a 30 second budget, and the client waits 35 seconds by
> default. Several serverless platforms cut a function off before either. For
> full-page captures of long pages, use [webhook delivery](#background-rendering)
> rather than holding the function open.

### Express

A long-running Node server is the simplest case: build the client once at module
scope and reuse it.

```js
import express from 'express';
import { Html2img, Html2imgError } from '@html2img/client';

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

app.use(express.json());

app.post('/api/og-image', async (request, response) => {
  try {
    const render = await client.html({
      html: card(request.body),
      width: 1200,
      height: 630,
      dpi: 2,
    });

    // Return the URL rather than proxying the bytes: the CDN serves it better
    // than your Node process will, and downloads are free.
    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);
```

## HTML to image

`POST /api/html` takes a complete HTML document and returns an image of the
rendered result. Inline your CSS in a `style` block, or reference remote
stylesheets and web fonts with `link` tags in the document head.

```js
const card = (post) => `<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;800&display=swap">
    <style>
      * { box-sizing: border-box }
      body {
        margin: 0; width: 1200px; height: 630px; padding: 80px;
        display: flex; flex-direction: column; justify-content: space-between;
        font-family: Inter, system-ui, sans-serif;
        background: linear-gradient(160deg, #0e1521, #16233a); color: #fff;
      }
      h1 { font-size: 64px; line-height: 1.1; margin: 0; font-weight: 800; letter-spacing: -0.02em }
      .meta { font-size: 22px; color: #aeb7c6 }
    </style>
  </head>
  <body>
    <h1>${escapeHtml(post.title)}</h1>
    <p class="meta">${escapeHtml(post.author)}</p>
  </body>
</html>`;

const response = await client.html({
  html: card(post),
  width: 1200,
  height: 630,
  dpi: 2, // retina: the file comes back 2400x1260
});
```

Escape any interpolated value. A title containing a stray angle bracket will
otherwise break the document, and user-supplied content in a template literal is
an injection waiting to happen.

> **Warning: Every URL must be publicly reachable**
>
> Chrome runs on our servers and fetches your fonts, images and stylesheets over
> the public internet. `http://localhost:3000/logo.png` resolves to nothing and
> comes out blank. Use absolute public URLs, inline small assets as data URIs, or
> tunnel your development server while iterating. Google Fonts always work.

### Injecting CSS after load

The [`css` option](https://html2img.com/docs/parameters/css/) is applied after the document loads, on
top of whatever the markup carries, so one template can produce two themes:

```js
const [dark, light] = await Promise.all([
  client.html({ html, css: 'body { background: #0f172a; color: #fff }', width: 1200, height: 630 }),
  client.html({ html, css: 'body { background: #fff; color: #0f172a }', width: 1200, height: 630 }),
]);
```

## Website screenshots

`POST /api/screenshot` captures a live, publicly reachable URL.

```js
// Viewport capture
await client.screenshot({ url: 'https://example.com', width: 1200, height: 630 });

// The whole scroll length of the page
await client.screenshot({ url: 'https://example.com/pricing', width: 1400, fullpage: true });

// One element, cropped to its own bounding box
await client.screenshot({ url: 'https://example.com/pricing', selector: '#plans', width: 1400 });

// Hide a cookie banner and a chat widget before capturing
await client.screenshot({
  url: 'https://example.com',
  css: '.cookie-banner, .intercom-launcher { display: none !important; }',
  width: 1440,
  height: 900,
  dpi: 2,
});

// Wait for late content
await client.screenshot({
  url: 'https://your-app.example.com/reports/42',
  waitForSelector: '#chart-rendered',
  msDelay: 400,
  width: 1440,
  height: 900,
});
```

Injected rules usually need `!important`, because the page's own styles win on
specificity. Prefer `waitForSelector` over `msDelay` wherever you control the
markup: it returns as soon as the element exists, where a delay always waits the
full duration. Behaviour in full:
[`fullpage`](https://html2img.com/docs/parameters/fullpage/),
[`selector`](https://html2img.com/docs/parameters/selector/),
[`dimensions`](https://html2img.com/docs/parameters/dimensions/),
[`dpi`](https://html2img.com/docs/parameters/dpi/),
[`wait_for_selector`](https://html2img.com/docs/parameters/wait_for_selector/),
[`ms_delay`](https://html2img.com/docs/parameters/ms_delay/).

## HTML to PDF

Set `format` to `'pdf'` on either method and the render comes back as an A4
portrait vector PDF: selectable text, embedded fonts and automatic pagination, for
the same single credit.

```js
const response = await client.html({
  html: invoiceHtml,
  format: 'pdf',
});

console.log(response.url); // https://i.html2img.com/....pdf
```

`width`, `height`, `dpi`, `fullpage` and `selector` are ignored in PDF mode. A PDF
cannot go in an `img` tag, so offer it as a download link or open it in a new tab.
See the [`format` docs](https://html2img.com/docs/parameters/format/) and the
[HTML to PDF API](https://html2img.com/html-to-pdf/) overview.

> **Note: scale_to_fit is a raw-request option**
>
> The API's [`scale_to_fit`](https://html2img.com/docs/parameters/scale-to-fit/) parameter, which shrinks
> a layout wider than the page instead of cropping it, is not exposed on the SDK's
> option types. Send that one request with [raw fetch](#without-the-sdk).

## Named templates

`POST /api/v1/templates/{slug}` renders a [named template](https://html2img.com/templates/) from a
JSON payload, with no markup of your own. Templates output PNG only.

```js
const response = await client.template('invoice-image', {
  invoice_number: 'INV-2026-0042',
  business_name: 'Coastline Coffee Co',
  client_name: 'Riverside Bakery',
  items: [
    { description: 'Wholesale beans', quantity: '50', unit_price: '£15.00', amount: '£750.00' },
  ],
  total: '£750.00',
});

console.log(response.template); // invoice-image
console.log(response.url);
```

JavaScript projects most often use the
[Open Graph image](https://html2img.com/templates/open-graph-image/),
[Twitter post](https://html2img.com/templates/twitter-post/),
[quote card](https://html2img.com/templates/quote-card/),
[code screenshot](https://html2img.com/templates/code-screenshot/) and
[GitHub social preview](https://html2img.com/templates/github-social-preview/) templates. Each one's
inputs are in the [template reference](https://html2img.com/docs/templates/).

## React and Next.js

Render on the server. A Next.js Route Handler or a Server Action keeps the key on
the server and gives the browser a URL to display.

### Route Handler

```ts
// app/api/og/route.ts
import { Html2img, Html2imgError, ValidationError } from '@html2img/client';
import { NextResponse } from 'next/server';

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

export async function POST(request: Request) {
  const { title, author } = await request.json();

  try {
    const response = await client.html({
      html: card({ title, author }),
      width: 1200,
      height: 630,
      dpi: 2,
    });

    return NextResponse.json({ url: response.url });
  } catch (error) {
    if (error instanceof ValidationError) {
      return NextResponse.json({ error: error.details }, { status: 422 });
    }
    if (error instanceof Html2imgError) {
      return NextResponse.json({ error: error.message }, { status: error.statusCode ?? 502 });
    }
    throw error;
  }
}
```

### Server Action

```ts
// app/actions.ts
'use server';

import { Html2img } from '@html2img/client';

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

export async function generateShareImage(postId: string) {
  const post = await getPost(postId);

  const response = await client.html({ html: card(post), width: 1200, height: 630, dpi: 2 });

  await savePostImage(postId, response.url);

  return response.url;
}
```

### Generating a page's Open Graph image ahead of time

The most useful pattern is not rendering on request but rendering on publish and
caching the URL. `generateMetadata` then reads a stored value:

```ts
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPost(params.slug);

  return {
    title: post.title,
    openGraph: { images: post.ogImageUrl ? [{ url: post.ogImageUrl, width: 1200, height: 630 }] : [] },
    twitter: { card: 'summary_large_image' },
  };
}
```

Rendering inside `generateMetadata` itself would spend a credit on every cold
request and add the render's latency to your time to first byte.

### Client components

A client component must never hold the key. Have it call your own route:

```tsx
'use client';

import { useState } from 'react';

export function ShareImageButton({ postId }: { postId: string }) {
  const [url, setUrl] = useState<string | null>(null);
  const [pending, setPending] = useState(false);

  async function generate() {
    setPending(true);
    try {
      // Your route holds the API key; this fetch is same-origin.
      const response = await fetch('/api/og', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ postId }),
      });
      if (!response.ok) throw new Error(`Request failed with ${response.status}`);
      setUrl((await response.json()).url);
    } finally {
      setPending(false);
    }
  }

  return (
    <>
      <button onClick={generate} disabled={pending}>
        {pending ? 'Generating...' : 'Generate share image'}
      </button>
      {url && <img src={url} alt="Generated share image" width={600} />}
    </>
  );
}
```

For a component that renders an image as soon as its inputs settle, a hook with
an `AbortController` cancels the in-flight request when the inputs change or the
component unmounts, so a slow render never overwrites a newer one:

```jsx
'use client';

import { useCallback, useEffect, useState } from 'react';

export function useShareImage(payload) {
  const [url, setUrl] = useState(null);
  const [error, setError] = useState(null);

  const render = useCallback(
    async (signal) => {
      try {
        // Your own route holds the API key.
        const response = await fetch('/api/og', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(payload),
          signal,
        });
        if (!response.ok) throw new Error(`Request failed with ${response.status}`);
        setUrl((await response.json()).url);
      } catch (err) {
        if (err.name !== 'AbortError') setError(err);
      }
    },
    [payload],
  );

  useEffect(() => {
    const controller = new AbortController();
    render(controller.signal);
    return () => controller.abort();
  }, [render]);

  return { url, error };
}
```

## Vue and Nuxt

The same rule: the render happens in a Nuxt server route, and the component talks
to that route.

```ts
// server/api/og.post.ts
import { Html2img, Html2imgError } from '@html2img/client';

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

export default defineEventHandler(async (event) => {
  const body = await readBody(event);

  try {
    const response = await client.html({
      html: card(body),
      width: 1200,
      height: 630,
      dpi: 2,
    });

    return { url: response.url };
  } catch (error) {
    if (error instanceof Html2imgError) {
      // Forward the real upstream status rather than a blanket 500
      throw createError({ statusCode: error.statusCode ?? 502, statusMessage: error.message });
    }
    throw error;
  }
});
```

```vue
<script setup lang="ts">
const props = defineProps<{ title: string }>();

// Runs on the server during SSR, and via the server route on the client.
const { data, error } = await useFetch('/api/og', {
  method: 'POST',
  body: { title: props.title },
});
</script>

<template>
  <img v-if="data?.url" :src="data.url" alt="Generated share image" />
  <p v-else-if="error">Could not generate the image.</p>
</template>
```

A composable keeps the request logic out of the component, and still talks only to
your own route:

```js
// composables/useShareImage.js
import { ref } from 'vue';

export function useShareImage() {
  const url = ref(null);
  const loading = ref(false);
  const error = ref(null);

  async function generate(payload) {
    loading.value = true;
    error.value = null;

    try {
      // Your own server route holds the API key.
      const response = await $fetch('/api/og', { method: 'POST', body: payload });
      url.value = response.url;
    } catch (err) {
      error.value = err;
    } finally {
      loading.value = false;
    }
  }

  return { url, loading, error, generate };
}
```

For a plain Vue SPA with no server of its own, put the call behind any backend you
control. `import.meta.env.VITE_*` values are compiled into the client bundle, so a
key placed there is public.

## Background rendering

Two options for keeping a render off a request.

**A worker.** Any queue you already run (BullMQ, a Cloudflare Queue, an SQS
consumer) can call the client and write the URL back to your database. There is
nothing runtime-specific in the package.

**Webhook delivery.** For captures that will not finish inside the 30 second
synchronous budget, pass a `webhookUrl`. The API responds immediately with
`status: "processing"` and a null `url`, then POSTs the finished URL to your
endpoint:

```js
const response = await client.screenshot({
  url: 'https://example.com/very-long-report',
  fullpage: true,
  webhookUrl: 'https://your-app.example.com/hooks/html2img',
});

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

The payload shape is in the
[`webhook_url` reference](https://html2img.com/docs/parameters/webhook-url/).

## Downloading and storing

The response carries a hosted URL, which is usually all you need. To keep your own
copy, fetch it:

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

const render = await client.html({ html: document, width: 1200, height: 630 });

const file = await fetch(render.url);
await writeFile('og/post-42.png', Buffer.from(await file.arrayBuffer()));
```

Or stream it into object storage:

```js
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({});
const file = await fetch(render.url);

await s3.send(new PutObjectCommand({
  Bucket: 'my-bucket',
  Key: `og/${post.id}.png`,
  Body: Buffer.from(await file.arrayBuffer()),
  ContentType: 'image/png',
}));
```

> **Tip: Free-tier renders expire**
>
> `response.expiresAt` is an ISO 8601 timestamp on the free tier and `null` on paid
> plans, where renders are hosted permanently. Download a copy if a free-tier image
> needs to outlive that window; upgrading also makes earlier renders permanent.

## Configuration

```js
import { Html2img } from '@html2img/client';

const client = new Html2img({
  apiKey: process.env.HTML2IMG_API_KEY,
  baseUrl: 'https://app.html2img.com', // default
  timeout: 35_000,                     // milliseconds, default
});
```

`Html2img.DEFAULT_BASE_URL` and `Html2img.DEFAULT_TIMEOUT` expose the defaults if
you want to reference them. The 35 second timeout sits just above the API's 30
second synchronous render budget.

### Supplying your own fetch

Pass a `fetch`-compatible function to route requests through a proxy, add retry or
logging middleware, or support a runtime without a global `fetch`. The client
still sends the `X-API-Key`, `Accept` and `Content-Type` headers:

```js
const client = new Html2img({
  apiKey: process.env.HTML2IMG_API_KEY,
  fetch: async (url, init) => {
    const started = Date.now();
    const response = await fetch(url, init);
    logger.info({ url, status: response.status, ms: Date.now() - started });
    return response;
  },
});
```

That hook is also where a retry policy belongs. The client deliberately does not
retry: a 5xx or a connection failure is worth retrying, a 4xx is not, and only you
know how many credits a retry may cost you.

## Error handling

Every failed request rejects with an `Html2imgError`. No raw `fetch` error escapes
the client. Invalid arguments are reported before a request is sent, as a native
`TypeError` or `RangeError`.

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

try {
  const response = await client.html({ html: document, width: 1200, height: 630 });
} catch (error) {
  if (error instanceof ValidationError) {
    // 400 or 422: the request was malformed, retrying will not help
    for (const [field, messages] of Object.entries(error.details)) {
      logger.warn(`html2img rejected ${field}: ${messages.join(', ')}`);
    }
  } else if (error instanceof InsufficientCreditsError) {
    // 402: out of credits, keep whatever image existed before
    logger.error(`html2img out of credits (${error.creditsRemaining} left)`);
  } else if (error instanceof TimeoutError) {
    // 504: re-send with a webhookUrl rather than retrying synchronously
  } else if (error instanceof Html2imgError) {
    logger.error({ status: error.statusCode, code: error.errorCode, body: error.payload });
  } else {
    throw error;
  }
}
```

| Error class | Rejected on |
| --- | --- |
| `AuthenticationError` | 401, missing or invalid API key |
| `InsufficientCreditsError` | 402, no credits remaining; exposes `creditsRemaining` |
| `NotSubscribedError` | 403, no active subscription |
| `NotFoundError` | 404, for example an unknown template slug |
| `ValidationError` | 400 or 422, with `details` per field |
| `TimeoutError` | 504, the synchronous render budget was exceeded |
| `ServerError` | 5xx, an unexpected renderer error |
| `ConnectionError` | The request never reached a response, including the client-side timeout |
| `Html2imgError` | Base type for all of the above |

The full status-code reference is in the
[getting started guide](https://html2img.com/docs/getting-started/).

## Testing

The `fetch` option is the seam. Passing a stub keeps your suite off the network
and off your credit balance, with no module mocking:

```js
import { Html2img } from '@html2img/client';

const fakeFetch = async () =>
  new Response(
    JSON.stringify({
      success: true,
      id: 'test-render',
      url: 'https://i.html2img.com/test.png',
      credits_remaining: 49,
    }),
    { status: 200, headers: { 'Content-Type': 'application/json' } },
  );

const client = new Html2img({ apiKey: 'test-key', fetch: fakeFetch });

const response = await client.html({ html: '<h1>Hi</h1>' });

expect(response.url).toBe('https://i.html2img.com/test.png');
```

Return a 402 body the same way to exercise the out-of-credits path, and assert on
the request by capturing the `init` argument your stub receives.

## Without the SDK

The API is one POST with a header. If a dependency is out of the question:

```js
async function render(endpoint, payload) {
  const response = await fetch(`https://app.html2img.com/api/${endpoint}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
      'X-API-Key': process.env.HTML2IMG_API_KEY,
    },
    body: JSON.stringify(payload),
    signal: AbortSignal.timeout(35_000),
  });

  const data = await response.json().catch(() => ({}));

  if (!response.ok || !data.success) {
    throw new Error(`html2img returned ${response.status}: ${data.message ?? data.error ?? 'unknown error'}`);
  }

  return data;
}

// HTML to image
const card = await render('html', { html: document, width: 1200, height: 630, dpi: 2 });

// Screenshot
const shot = await render('screenshot', { url: 'https://example.com', fullpage: true });

// PDF, including the scale_to_fit option the SDK does not expose
const pdf = await render('html', { html: document, format: 'pdf', scale_to_fit: true });

// Named template
const invoice = await render('v1/templates/invoice-image', {
  invoice_number: 'INV-2026-0042',
  total: '£750.00',
});
```

Note the snake_case: the SDK accepts camelCase options and converts them, but the
wire format is `webhook_url`, `ms_delay`, `wait_for_selector` and `scale_to_fit`.

A retry wrapper, if you want one, should back off on 5xx and stop on everything
else. Do not retry a 504: a timed-out render may still be completing, and each
attempt can spend another credit.

```js
async function renderWithRetry(endpoint, payload, attempts = 3) {
  for (let attempt = 0; attempt < attempts; attempt += 1) {
    try {
      return await render(endpoint, payload);
    } catch (error) {
      const status = Number(String(error.message).match(/returned (\d+)/)?.[1]);
      const retryable = status >= 500 && status !== 504;

      if (!retryable || attempt === attempts - 1) throw error;

      await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
    }
  }
}
```

## Package reference

**`new Html2img(apiKey)`** or **`new Html2img(options)`**, where options are
`apiKey`, `baseUrl`, `timeout` (milliseconds) and `fetch`.

| Method | Signature |
| --- | --- |
| `html()` | `html(options: HtmlOptions): Promise<RenderResponse>` |
| `screenshot()` | `screenshot(options: ScreenshotOptions): Promise<RenderResponse>` |
| `template()` | `template(slug: string, data?: Record<string, unknown>): Promise<RenderResponse>` |

**Render options.** Shared by both methods: `css`, `width`, `height`, `fullpage`,
`dpi`, `webhookUrl`, `msDelay`, `waitForSelector`, `format`. `html()` also
requires `html`; `screenshot()` also requires `url` and additionally accepts
`selector`. Anything left undefined is omitted from the request, so the server
applies its own default. Ranges and behaviour are in the
[parameter reference](https://html2img.com/docs/parameters/).

**`RenderResponse`:**

| Member | Type | Meaning |
| --- | --- | --- |
| `success` | `boolean` | Whether the API reported success |
| `id` | `string \| null` | The render id |
| `url` | `string \| null` | CDN URL, null while an async job is pending |
| `expiresAt` | `string \| null` | ISO 8601 expiry on the free tier, null on paid plans |
| `creditsRemaining` | `number \| null` | Credits left after this call |
| `status` | `string \| null` | `"processing"` for accepted async jobs |
| `message` | `string \| null` | Human-readable message, when provided |
| `template` | `string \| null` | Template slug, on template renders |
| `isProcessing()` | `boolean` | Whether the job is still rendering |
| `raw` | `object` | The full decoded JSON payload |

**Exported types:** `Html2imgOptions`, `FetchLike`, `CommonRenderOptions`,
`HtmlOptions`, `ScreenshotOptions`, `Format`, `ErrorPayload`,
`Html2imgErrorOptions`.

## Troubleshooting

**`TypeError: The apiKey must not be empty.`** The environment variable did not
reach the process. In Next.js, only `NEXT_PUBLIC_*` variables reach the client, and
nothing reaches the client bundle by accident: if this fires in a component, the
call is running in the wrong place. Move it to a route handler or a server action.

**My key ended up in the browser bundle.** Anything prefixed `NEXT_PUBLIC_`,
`VITE_` or `REACT_APP_` is inlined at build time and shipped. Rename the variable,
rotate the key from your [dashboard](https://app.html2img.com/dashboard), and call
the API from the server.

**`No fetch implementation is available.`** The runtime has no global `fetch`.
Upgrade to Node 18 or newer, or pass one through the `fetch` option.

**Images and fonts are missing from the render.** Chrome fetches them over the
public internet. `http://localhost:3000` is invisible to it. Use absolute public
URLs, inline small assets as data URIs, or tunnel your dev server.

**The image is blank or half-drawn.** The capture happened before the content did.
Add `waitForSelector` pointing at something the finished page contains, or
`msDelay` as a fallback for iframe content, which selectors cannot see.

**A `ConnectionError` mentioning a timeout on a serverless platform.** Either the
render exceeded the client's 35 second timeout or the platform cut the function
off first. Use `webhookUrl` for long captures.

**The PDF will not display in an `img` tag.** It never will. Link to it, or embed
it in an `object` or `iframe`.

**Options seem to be ignored.** Check the case. The SDK takes `waitForSelector`;
raw requests take `wait_for_selector`. Mixing the two silently drops the option,
because unknown keys are not sent.

## FAQ

**Can I call the API directly from the browser?**
No. The key spends credits and there is no browser-safe scope for it. Put a route
in front of it, rate-limit that route, and return only the URL.

**Does the SDK work in Cloudflare Workers?**
Yes. It uses only the standard `fetch` API with no Node built-ins, so Workers,
Deno Deploy and other edge runtimes work without a compatibility flag.

**How do I generate Open Graph images for a static site?**
Render at build time and commit the files. For a repository-driven site, the
[GitHub Action](https://html2img.com/integrations/github-actions/) does exactly this and skips
renders whose inputs have not changed.

**What happened to the React and Vue guides?**
They are the [React and Next.js](#react-and-nextjs) and
[Vue and Nuxt](#vue-and-nuxt) sections above. The advice was the same in both, and
the important part (render on the server) is easier to keep straight on one page.

**Is a PDF more expensive than an image?**
No. One render is one credit, however many pages the PDF has.

**How do I check my balance without spending a credit?**
Call [`GET /api/me`](https://html2img.com/docs/account/). It reports your plan and remaining credits
and renders nothing.

**Does the client retry failed requests?**
No, deliberately. A retry policy depends on how much a duplicate render would cost
you. Add one in your own `fetch` wrapper, and never retry a 504.
