This page shows how to call the RenderScreenshot API from Node.js with the official renderscreenshot package. The examples are TypeScript and ESM, and they cover the places Node code really runs: a script, an Express route, a Next.js App Router handler, a batch job and a vitest suite. For the other languages, go back to the screenshot API code examples hub.
Last tested: 2026-10-18 with Node 22.18,
renderscreenshot1.1.2, Express 5.3.0, vitest 5.0.5 and TypeScript 7.0.2 (tsc --strictpassed on every listing). The code ran against a local stub of our API that returns the documented image, JSON, batch and error responses (including a 429 withRetry-After). We did not call the live API for this page. We did not runnext build; the two Next.js files were called directly as functions.
Install
npm install renderscreenshot
The package needs Node.js 20 or later. It has no runtime dependencies because it uses the global fetch. Keep the key in an environment variable:
export RS_API_KEY=rs_live_xxxxx
Quick start
Node 22.18 runs .ts files directly, so this file works with node quickstart.ts and no build step:
import { writeFile } from 'node:fs/promises'; import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY!); const png = await client.take( TakeOptions.url('https://example.com').preset('og_card').blockCookieBanners(), ); await writeFile('example-og.png', png); console.log(`saved ${png.byteLength} bytes`);
take() resolves to a Buffer. TakeOptions is a fluent builder, and the SDK turns it into the nested JSON body of POST /v1/screenshot. The og_card preset gives a 1200x630 PNG; the full list is in the presets reference.
Stream a large capture to disk (raw HTTP)
A full-page capture of a long page can be several megabytes. The SDK buffers the whole response, which is fine for social cards but wasteful in a worker that writes many big files. For those, skip the SDK and use fetch, then pipe the body to a file. This is also the fallback if you cannot add a dependency:
import { createWriteStream } from 'node:fs'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import type { ReadableStream } from 'node:stream/web'; const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://example.com', capture: { mode: 'full_page' }, output: { format: 'png' }, }), signal: AbortSignal.timeout(90_000), }); if (!res.ok || !res.body) { const { error } = await res.json(); console.error(`${res.status} ${error.code}: ${error.message} (${error.docs})`); process.exit(1); } console.log('title:', res.headers.get('x-screenshot-title')); console.log('cdn:', res.headers.get('x-cache-url')); await pipeline(Readable.fromWeb(res.body as ReadableStream), createWriteStream('example-full.png'));
Two details matter. The cast on res.body is needed because TypeScript's DOM ReadableStream type and the one from node:stream/web do not match under --strict, although they are the same object at runtime. And pipeline (not .pipe()) rejects if either side fails, so a broken disk write cannot leave a half-written file unnoticed. The response headers carry the page title and the public CDN link before you read a byte of the body; see response headers.
One client with a retry rule
Put the client and the retry rule in one module. Every other example on this page imports capture() from it:
// capture.ts import { Client, TakeOptions, RenderScreenshotError } from 'renderscreenshot'; export const client = new Client(process.env.RS_API_KEY!, { timeout: 60_000 }); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); // Retry on 429 and 5xx only. Branch on httpStatus: the SDK puts the API's // own code string (e.g. 'too_many_requests') in error.code. export async function capture(url: string, attempts = 3): Promise<Buffer> { for (let i = 1; ; i++) { try { return await client.take(TakeOptions.url(url).preset('og_card')); } catch (err) { if (!(err instanceof RenderScreenshotError)) throw err; const transient = err.httpStatus === 429 || err.httpStatus >= 500; if (!transient || i === attempts) throw err; await sleep((err.retryAfter ?? 2 ** i) * 1000); } } }
Why the rule uses httpStatus, not retryable
RenderScreenshotError has httpStatus, code, message, retryable and retryAfter. When we pointed the SDK at our stub, it reported this for three failures:
| Stub response | httpStatus |
code |
retryable |
retryAfter |
|---|---|---|---|---|
429 too_many_requests, Retry-After: 1 |
429 | too_many_requests |
false |
1 |
422 timeout (target page) |
422 | timeout |
true |
undefined |
400 invalid_url |
400 | invalid_url |
false |
undefined |
The first row is the trap. The SDK computes retryable from its own short list of codes (rate_limited, timeout, render_failed, internal_error), but it copies the API's code into code unchanged. The API answers a rate limit with too_many_requests, so retryable is false on exactly the error you most want to retry. Check the status instead. The rate limit is per account and all your keys share it, so a retry storm from one service slows every other service on the account. Always wait for retryAfter. The error codes index lists every code, and error handling and retries covers 422 target errors in depth.
Express route
This route serves an Open Graph image for any page of your site. It checks the slug before it builds a URL from it, so a request cannot make the API capture an arbitrary page:
import express from 'express'; import { RenderScreenshotError } from 'renderscreenshot'; import { capture } from './capture.ts'; const app = express(); app.get('/og/:slug.png', async (req, res) => { const slug = req.params.slug; if (!/^[a-z0-9-]+$/.test(slug)) return res.sendStatus(404); try { const png = await capture(`https://example.com/${slug}`); res.set('Cache-Control', 'public, max-age=86400').type('png').send(png); } catch (err) { if (err instanceof RenderScreenshotError) { console.error('og render failed', err.httpStatus, err.code); return res.sendStatus(err.httpStatus === 429 ? 503 : 502); } throw err; } }); app.listen(3005);
Express 5 forwards a rejected promise from an async handler to the error middleware, so the final throw is safe. The route answers 503 on a rate limit so that your CDN or the crawler tries again later, and 502 for a render failure. For a complete service with validation, cache headers and supertest tests, see the Express screenshot proxy.
Next.js App Router
A route handler that returns the image
// app/og/[slug]/route.ts import { RenderScreenshotError } from 'renderscreenshot'; import { capture } from '@/lib/capture'; export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; if (!/^[a-z0-9-]+$/.test(slug)) return new Response('Not found', { status: 404 }); try { const png = await capture(`https://example.com/blog/${slug}`); return new Response(new Uint8Array(png), { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, s-maxage=86400' }, }); } catch (err) { if (err instanceof RenderScreenshotError) return new Response(null, { status: 502 }); throw err; } }
A signed URL in generateMetadata
The route handler costs one function invocation per crawler hit. A signed URL moves that work off your server: the crawler loads the image straight from GET /v1/screenshot, and the URL holds only your public key ID and an HMAC signature. The SvelteKit guide signs the same URL with Web Crypto in a load function.
// app/blog/[slug]/page.tsx (metadata part) import { Client, TakeOptions } from 'renderscreenshot'; const signer = new Client(process.env.RS_API_KEY!, { signingKey: process.env.RS_SIGNING_KEY, // rs_secret_... publicKeyId: process.env.RS_PUBLIC_KEY_ID, // rs_pub_... }); const WEEK = 7 * 24 * 60 * 60 * 1000; export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; // Same expiry for a whole week, so the URL (and the cache entry) stays stable. const expires = new Date((Math.floor(Date.now() / WEEK) + 2) * WEEK); const image = signer.generateUrl( TakeOptions.url(`https://example.com/blog/${slug}`).preset('og_card'), expires, ); return { openGraph: { images: [{ url: image, width: 1200, height: 630 }] } }; }
The expiry is the part most examples get wrong. Date.now() + 7 days gives a different expires, and so a different signature, on every render. Every crawler then sees a new image URL. Rounding to a week boundary keeps the URL identical for a week and always leaves 7 to 14 days of validity, well inside the 30-day maximum. Only GET options go into a signed URL; waitForSelector() and other POST-only options are dropped.
Batch
For up to 10 URLs, POST /v1/batch answers synchronously. Each item has its own status, so one bad page does not fail the rest:
const pages = ['https://example.com/', 'https://example.com/pricing', 'https://example.com/docs']; const batch = await client.batch(pages, TakeOptions.url('').preset('og_card')); console.log(`${batch.completed}/${batch.total} rendered`); for (const item of batch.results ?? []) { console.log(item.status === 'completed' ? `${item.url} -> ${item.image?.image_url}` : `${item.url} failed: ${item.error}`); }
The result's image link is image.image_url, and a failed item's error is a plain message string. Larger batches return processing and a batch ID; poll with client.getBatch(id).
Test with vitest
Spy on Client.prototype.take so no request leaves the test. Constructing a real RenderScreenshotError keeps the test honest about the fields your code reads:
import { afterEach, expect, test, vi } from 'vitest'; import { Client, RenderScreenshotError } from 'renderscreenshot'; import { capture } from './capture.ts'; afterEach(() => vi.restoreAllMocks()); test('retries once after a 429 and returns the image', async () => { const take = vi .spyOn(Client.prototype, 'take') .mockRejectedValueOnce(new RenderScreenshotError(429, 'rate_limited', 'slow down', 0)) .mockResolvedValueOnce(Buffer.from('png-bytes')); await expect(capture('https://example.com')).resolves.toEqual(Buffer.from('png-bytes')); expect(take).toHaveBeenCalledTimes(2); }); test('does not retry a bad URL', async () => { const take = vi .spyOn(Client.prototype, 'take') .mockRejectedValue(new RenderScreenshotError(400, 'invalid_url', 'bad url')); await expect(capture('nope')).rejects.toMatchObject({ httpStatus: 400, code: 'invalid_url' }); expect(take).toHaveBeenCalledTimes(1); });
A retryAfter of 0 keeps the test fast. For contract tests against a fake HTTP server, see testing code that calls the API.
Source, changelog and next steps
- SDK source: github.com/Render-Screenshot/rs-node, with release notes in its CHANGELOG.
- Full method list: the Node.js SDK reference.
- Rendering HTML strings instead of URLs: HTML to image in Node.js.
Get a free API key to run these examples against the real API. The free plan includes 150 credits every month.