Many "screenshot server" repos on GitHub are an Express app that drives PhantomJS or an old Puppeteer, with a /screenshot?url= route and no checks. PhantomJS is unmaintained, and a headless Chrome inside your web process brings memory spikes, zombie processes and font packages to your container. This page replaces that pattern with a small Express 5 service. Chrome runs on our side; your process only checks input, forwards one request and streams the bytes back.
The page covers four parts: the proxy route, a gallery page with signed URLs, the error and cache rules, and tests with supertest. The SDK basics and a slug-based og:image route are on the Node.js screenshot API page, so this page does not repeat them.
Last tested: 2026-10-18 with Node 22.18, Express 5.3.0,
renderscreenshot1.1.2, TypeScript 7.0.2 (tsc --strictpassed), vitest 5.0.5 and supertest 7.3.1. The tests ran against a local HTTP stub that returns the documented image and 429 responses. We did not call the live API for this page.
How the request flows
sequenceDiagram participant B as Browser or CDN participant E as Express /shot participant A as RenderScreenshot API B->>E: GET /shot?url=https://example.com/&width=800 E->>E: allowlist, width and format checks E->>A: POST /v1/screenshot (Bearer rs_live_...) A-->>E: image/webp stream E-->>B: same stream + Cache-Control
Put a CDN in front of /shot and most requests never reach Express. The Cache-Control header that the route sets is what makes that work.
Install
npm install express@5 renderscreenshot npm install -D typescript @types/express @types/node vitest supertest @types/supertest
The proxy uses the global fetch and needs no SDK. The SDK is only for signing URLs in the gallery. Node 22.18 runs the .ts files directly (node server.ts), because type stripping is on by default. Set "type": "module" in package.json, or Node prints a warning and reparses each file as ESM.
The proxy route
// server.ts import express from 'express'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import type { ReadableStream as NodeReadableStream } from 'node:stream/web'; const API = process.env.RS_API_BASE ?? 'https://api.renderscreenshot.com'; const KEY = process.env.RS_API_KEY ?? ''; const ALLOWED_HOSTS = new Set(['example.com', 'www.example.com']); const FORMATS = new Set(['png', 'jpeg', 'webp']); export const app = express(); app.disable('x-powered-by'); app.get('/shot', async (req, res) => { let target: URL; try { target = new URL(String(req.query.url ?? '')); } catch { return res.status(400).json({ error: 'url must be an absolute URL' }); } if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.hostname)) { return res.status(400).json({ error: 'host not allowed' }); } const width = Number(req.query.width ?? 1200); if (!Number.isInteger(width) || width < 320 || width > 1920) { return res.status(400).json({ error: 'width must be 320 to 1920' }); } const format = String(req.query.format ?? 'webp'); if (!FORMATS.has(format)) return res.status(400).json({ error: 'format must be png, jpeg or webp' }); const upstream = await fetch(`${API}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ url: target.href, viewport: { width, height: Math.round(width * 0.525) }, output: { format }, }), signal: AbortSignal.timeout(75_000), }); if (!upstream.ok || !upstream.body) { const body = await upstream.json().catch(() => null); console.error('render failed', upstream.status, body?.error?.code, body?.request_id); if (upstream.status === 429) res.set('Retry-After', upstream.headers.get('retry-after') ?? '30'); return res.sendStatus(upstream.status === 429 ? 503 : 502); } res.set({ 'Content-Type': upstream.headers.get('content-type') ?? `image/${format}`, 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800', }); const title = upstream.headers.get('x-screenshot-title'); if (title) res.set('X-Page-Title', title); await pipeline(Readable.fromWeb(upstream.body as NodeReadableStream), res); }); if (process.argv[1]?.endsWith('server.ts')) app.listen(Number(process.env.PORT ?? 3000));
Why each part is there:
- The allowlist is the security boundary. Without it, anyone who finds
/shotcan spend your credits on any page. The API already refuses private and internal addresses withblocked, so the allowlist is about cost and abuse, not SSRF. Replace the two hosts with your own. - Every number has a range.
widthcomes from the query string, soNumber()can giveNaN, a float or1e9. The checks run beforefetch, and a rejected request costs nothing. - The body is nested JSON.
POST /v1/screenshottakesviewport.widthandoutput.format, not the flatwidthandformatof the GET endpoint. The raw HTTP guide has the full mapping. pipelinestreams, it does not buffer. A full-page PNG can be several megabytes.Readable.fromWebturns the fetch body into a Node stream, andpipelineends the response and frees the socket if the client goes away. Theas NodeReadableStreamcast is only for TypeScript: the DOM and Node stream types do not match in@types/node, but the object at runtime is the same.- The page title comes along. Binary responses carry
X-Screenshot-Title,X-Screenshot-DescriptionandX-Screenshot-Faviconfrom the real page (response headers). The route passes the title on, so a link-preview client gets both from one request. - The timeout is above the API's. The longest render wait is 60 seconds (wait options).
AbortSignal.timeout(75_000)leaves room for the network. If it fires,fetchrejects, and Express 5 sends the rejection to the error middleware, so the route needs notryaround it.
Cache headers, and who pays for a miss
Each request that reaches the API is a render. max-age=86400 lets a CDN keep the image for a day, and stale-while-revalidate lets it serve the old image while it fetches a new one. The API also has its own cache with a default TTL of one day, but it reuses an earlier render only when the request asks for it with cache.available. Without that field, two CDN misses for the same URL are two renders and two credits. To share one render, add cache: { available: true } to the JSON body; a reused render costs 0 credits but still counts as a request. Use the CDN as the first layer: a CDN hit never touches your process or your rate limit.
What a 429 means here
The rate limit is per account, and every key shares it (rate limits). If the proxy retried on its own, one busy page would slow down your other services too. So the route turns a 429 into a 503 with the API's Retry-After, and lets the CDN or the client wait. Other failures become 502. The log line keeps the API's error.code and request_id for support. The error codes index lists every code.
A gallery with signed URLs
For pages that you render yourself, you do not need the proxy at all. Sign a GET URL per image and let the browser fetch it from the API. Your API key never leaves the server, and the image request never reaches Express:
// gallery.ts import express from 'express'; 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 DAY = 86_400_000; const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); export function thumb(pageUrl: string, now = Date.now()): string { // Midnight UTC + 8 days: one URL per page per day, so browsers and CDNs can cache it. const expires = new Date(Math.floor(now / DAY) * DAY + 8 * DAY); return signer.generateUrl( TakeOptions.url(pageUrl).width(1280).height(800).format('webp').quality(70), expires, ); } export const gallery = express.Router(); gallery.get('/gallery', (_req, res) => { const sites = ['https://example.com/', 'https://example.org/']; const items = sites .map((s) => `<li><img src="${escapeHtml(thumb(s))}" width="640" height="400" loading="lazy" alt="${escapeHtml(s)}"></li>`) .join(''); res.type('html').send(`<!doctype html><ul>${items}</ul>`); });
Mount it with app.use(gallery). Two details matter:
- The HTML is escaped. This page builds markup with a template string, so the
&in the signed URL and any character in a site URL must be escaped. In a real app the site list often comes from users, and an unescaped"would let one of them inject attributes. If you use a view engine such as EJS or Pug, its default escaping does the same job. - The expiry is aligned to the day.
Date.now() + 7 dayswould give a new URL on every page view, and no cache could reuse the image. Rounding down to midnight UTC keeps the URL the same all day. The expiry is always 7 to 8 days ahead, inside the 30-day maximum. Signed URLs work only forGETandHEADon/v1/screenshot.
Test with supertest and a stub API
Do not mock fetch for the proxy. Start a real HTTP server on a random port that answers like the API, and point RS_API_BASE at it. The test then covers the stream, the headers and the JSON body that the route sends:
// test/server.test.ts import http from 'node:http'; import type { AddressInfo } from 'node:net'; import request from 'supertest'; import { afterAll, beforeAll, expect, it, vi } from 'vitest'; let stub: http.Server; let calls: { auth?: string; body: unknown }[] = []; let mode: 'ok' | 'limited' = 'ok'; beforeAll(async () => { stub = http.createServer((req, res) => { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { calls.push({ auth: req.headers.authorization, body: JSON.parse(body) }); if (mode === 'limited') { res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '7' }); return res.end(JSON.stringify({ error: { type: 'rate_limit_error', code: 'too_many_requests', message: 'slow down', retryable: true }, request_id: 'r1', })); } res.writeHead(200, { 'Content-Type': 'image/webp', 'X-Screenshot-Title': 'Example Domain' }); res.end(Buffer.from('RIFFfakewebp')); }); }); await new Promise<void>((r) => stub.listen(0, r)); process.env.RS_API_BASE = `http://127.0.0.1:${(stub.address() as AddressInfo).port}`; process.env.RS_API_KEY = 'rs_live_test'; process.env.RS_SIGNING_KEY = 'rs_secret_test'; process.env.RS_PUBLIC_KEY_ID = 'rs_pub_test'; }); afterAll(() => stub.close()); const load = async () => (await import('../server.ts')).app; it('streams the image with cache headers', async () => { mode = 'ok'; calls = []; const res = await request(await load()).get('/shot?url=https://example.com/&width=800'); expect(res.status).toBe(200); expect(res.headers['content-type']).toBe('image/webp'); expect(res.headers['cache-control']).toContain('max-age=86400'); expect(res.headers['x-page-title']).toBe('Example Domain'); expect(calls[0]).toEqual({ auth: 'Bearer rs_live_test', body: { url: 'https://example.com/', viewport: { width: 800, height: 420 }, output: { format: 'webp' } }, }); }); it('refuses other hosts before spending a credit', async () => { calls = []; for (const url of ['http://example.com/', 'https://evil.test/', 'javascript:alert(1)']) { expect((await request(await load()).get('/shot').query({ url })).status).toBe(400); } expect((await request(await load()).get('/shot?url=https://example.com/&width=5000')).status).toBe(400); expect(calls).toHaveLength(0); }); it('maps a 429 to 503 with Retry-After', async () => { mode = 'limited'; vi.spyOn(console, 'error').mockImplementation(() => {}); const res = await request(await load()).get('/shot?url=https://example.com/'); expect(res.status).toBe(503); expect(res.headers['retry-after']).toBe('7'); }); it('signs one stable URL per day', async () => { const { thumb } = await import('../gallery.ts'); const a = thumb('https://example.com/', Date.UTC(2026, 9, 18, 1)); expect(thumb('https://example.com/', Date.UTC(2026, 9, 18, 23))).toBe(a); expect(new URL(a).searchParams.get('expires')).toBe(String(Date.UTC(2026, 9, 26) / 1000)); expect(a).not.toMatch(/rs_secret|rs_live/); });
The environment variables are set in beforeAll, and the modules are imported after it, because server.ts and gallery.ts read them at import time. The last test needs no network: generateUrl() only computes an HMAC. In our run, the "refuses" test also proved that no request reached the stub, which is the property that protects your credits. For contract tests in other languages, see the SDK testing guide.
When to keep Puppeteer instead
Run Chrome yourself if every capture needs a logged-in browser session that you cannot pass as cookies or headers, or if you must not send page content to a third party. If you go that way, read scaling Puppeteer screenshots first: a pool, a queue and crash recovery are the parts that the 60 lines above leave out.
Get a free API key, set it as RS_API_KEY, add your hosts to ALLOWED_HOSTS and run node server.ts. The free plan includes 150 credits every month.