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.
npm install @html2img/client Requires: Node.js 18 or newer, or any runtime with a global fetch. Every account starts with 50 free credits, no card needed.
See also GitHub Actions Python PHP
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.
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 and as vector PDFs by email.
- Screenshots of live URLs for link previews, directory thumbnails or visual regression checks, through the 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 |
Older Node versions, or any runtime without a global fetch, can pass their own
implementation through the fetch option.
Installation
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:
HTML2IMG_API_KEY=your-api-key
Quick start
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.
import { Html2img, ValidationError } from '@html2img/client';
CommonJS
A CommonJS build ships alongside it, with the same exports:
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:
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.
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.
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:
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 });
}
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 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.
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.
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.
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 is applied after the document loads, on
top of whatever the markup carries, so one template can produce two themes:
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.
// 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,
selector,
dimensions,
dpi,
wait_for_selector,
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.
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 and the
HTML to PDF API overview.
The API’s 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.
Named templates
POST /api/v1/templates/{slug} renders a named template from a
JSON payload, with no markup of your own. Templates output PNG only.
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, Twitter post, quote card, code screenshot and GitHub social preview templates. Each one’s inputs are in the template reference.
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
// 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
// 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:
// 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:
'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:
'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.
// 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;
}
});
<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:
// 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:
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.
Downloading and storing
The response carries a hosted URL, which is usually all you need. To keep your own copy, fetch it:
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:
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',
}));
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
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:
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.
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.
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:
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:
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.
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.
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, 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 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 and 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. 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.
Related integrations
The same API and the same key across every stack. These are the neighbours of the JavaScript integration.
Start rendering from JavaScript
50 free credits, no card required. One credit renders one image or one PDF.