You run a page that lists other people's websites: search results, a link collection, a portfolio of client sites, a "built with" showcase. Each row needs a picture of the site, the sites do not give you one, and you cannot ask 5,000 site owners for a logo. Capture each homepage once in a background job, cut it into the sizes your layouts need, show a placeholder until the files exist, and refresh on a schedule. This page gives the request, the job, the placeholder and the cost of that plan.
It belongs to the use cases section. If your listings come from user submissions, the directory websites page adds the submission flow.
The request gallery
These are the requests behind a typical thumbnail set. Every row is one render of https://example.com at a 1280x800 viewport, the desktop layout most sites design their first screen for.
| Use | Output size | Request body (POST /v1/screenshot) |
Credits |
|---|---|---|---|
| Master for local resizing | 1280x800 PNG | {"url": "https://example.com", "viewport": {"width": 1280, "height": 800}, "output": {"format": "png"}} |
1 |
| Card grid | 640x400 WebP | {"url": "https://example.com", "output": {"format": "webp", "quality": 80, "resize": {"width": 640, "height": 400, "fit": "cover"}}} |
1 |
| List row | 320x200 WebP | {"url": "https://example.com", "output": {"format": "webp", "resize": {"width": 320, "height": 200, "fit": "cover"}}} |
1 |
| Search result icon | 160x100 WebP | {"url": "https://example.com", "output": {"format": "webp", "resize": {"width": 160, "height": 100, "fit": "cover"}}} |
1 |
Because 1280x800 and the three smaller boxes share the 16:10 ratio, cover crops nothing here. Change the box ratio, to a square avatar for example, and cover cuts the edges while inside keeps the whole page and lets one side be shorter. The resize guide shows all five fit modes on the same input.
One capture or four?
Each request is one render and one credit. Four resized requests per site give you four CDN links and no image code; one master capture plus local resizing costs a quarter as much but needs your own storage.
| Approach | Credits per site | Where files live | Pick it when |
|---|---|---|---|
Four requests with output.resize |
4 | Our CDN, until X-Cache-Expires |
You have a few hundred sites and no image pipeline |
| One master capture, resize with sharp | 1 | Your bucket or CDN | You list thousands of sites, or you keep thumbnails longer than 30 days |
Signed GET URL in an <img> tag |
1 per cache miss | Our CDN | A handful of previews per page, rendered on first view |
The signed URL row has a catch. output.resize is a POST option, so a signed GET URL returns the full viewport-sized image and the browser shrinks it. That is fine for a detail page with one preview, and wasteful for a results page with 50.
How it works
flowchart LR A[Listing page] -->|thumbnail missing| P[Placeholder tile] A -->|enqueue| Q[Background job] Q -->|POST /v1/screenshot<br/>cache.available, ttl 30 days| R[RenderScreenshot] R -->|PNG + X-Screenshot-Title<br/>+ X-Screenshot-Favicon| Q Q -->|sharp: 4 sizes| S[(Your bucket)] Q -->|title, favicon, file keys| D[(Database)] D --> A
The page never waits for a browser. It reads the database, finds no thumbnail for a new site, shows a placeholder and queues a job. The job makes one capture, writes four WebP files, and stores the page title and favicon that came back in the response headers. The next page view shows the real image.
The job
This script makes one capture and writes the four sizes. It asks for a lossless PNG master so the WebP files are encoded once, not twice.
// thumbnails.mjs: one capture, four thumbnail sizes, plus title and favicon for the placeholder. // Usage: RS_API_KEY=rs_live_... node thumbnails.mjs https://example.com ./thumbs import { mkdir } from 'node:fs/promises'; import path from 'node:path'; import sharp from 'sharp'; const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com'; const SIZES = [ { name: 'lg', width: 1280, height: 800 }, // hover or detail preview { name: 'md', width: 640, height: 400 }, // card grid (320x200 CSS px at 2x) { name: 'sm', width: 320, height: 200 }, // list row { name: 'xs', width: 160, height: 100 }, // search result or autocomplete ]; // Header values with non-ASCII text arrive percent-encoded; plain ASCII may contain a bare '%'. function headerText(res, name) { const value = res.headers.get(name); if (!value) return null; try { return decodeURIComponent(value); } catch { return value; } } export async function captureThumbnails(url, outDir) { const res = await fetch(`${API}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ url, viewport: { width: 1280, height: 800 }, output: { format: 'png' }, wait: { until: 'networkidle', timeout: 20 }, block: { cookie_banners: true, ads: true, chat_widgets: true }, cache: { available: true, ttl: 2592000 }, // reuse a render up to 30 days old for 0 credits }), signal: AbortSignal.timeout(60_000), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(`Capture of ${url} failed: HTTP ${res.status} ${body.error?.code ?? ''}`); } const master = Buffer.from(await res.arrayBuffer()); await mkdir(outDir, { recursive: true }); const files = {}; for (const { name, width, height } of SIZES) { const file = path.join(outDir, `${name}.webp`); await sharp(master).resize({ width, height, fit: 'cover', position: 'top' }).webp({ quality: 80 }).toFile(file); files[name] = file; } return { files, title: headerText(res, 'x-screenshot-title'), favicon: headerText(res, 'x-screenshot-favicon'), reused: res.headers.get('x-cache-hit') === 'true', capturedUntil: res.headers.get('x-cache-expires'), }; } if (import.meta.url === `file://${process.argv[1]}`) { const [url = 'https://example.com', outDir = './thumbs'] = process.argv.slice(2); try { console.log(await captureThumbnails(url, outDir)); } catch (err) { console.error(err.message); process.exitCode = 1; } }
Three settings carry the weight. block.cookie_banners and block.chat_widgets keep a consent dialog from becoming the thumbnail, which at 160x100 is all a user would see. position: 'top' in sharp keeps the header and hero when a ratio does crop. And cache.available with a 30-day TTL means a second job for the same URL inside that window, from a retry or a duplicate submission, returns the earlier render for 0 credits.
Tested on 2026-10-18 with Node.js 22.18.0 and sharp 0.35.5 against a local stub that returns the API's documented headers. The run wrote 1280x800, 640x400, 320x200 and 160x100 files, reported reused: true on the second call, and exited 1 on an HTTP 401.
The placeholder
The placeholder has the same box as the final image, so the grid does not jump when thumbnails arrive. Escape every value, because the title comes from someone else's page.
// thumb-html.mjs: the image when thumbnails exist, a sized placeholder until then. const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); export function thumbnailHtml(site) { const host = new URL(site.url).hostname.replace(/^www\./, ''); if (site.thumbs) { const alt = `Homepage of ${site.title ?? host}`; return `<img src="${esc(site.thumbs.sm)}" srcset="${esc(site.thumbs.sm)} 1x, ${esc(site.thumbs.md)} 2x" ` + `width="320" height="200" loading="lazy" alt="${esc(alt)}">`; } return `<div class="thumb thumb--pending" role="img" aria-label="${esc(`Preview of ${host} is on its way`)}">` + `<span>${esc(host.charAt(0).toUpperCase())}</span><small>${esc(host)}</small></div>`; }
Give .thumb a fixed aspect-ratio: 16 / 10 and a neutral background. When the job has also stored the favicon URL, show it inside the placeholder for sites whose capture failed, so a broken site still gets a recognizable tile.
Cache and refresh plan
| Listing type | Refresh | How |
|---|---|---|
| Search results over a fixed index | Every 30 days | Run the job for every site with cache.ttl: 2592000. Re-runs inside 30 days cost nothing |
| Portfolio or showcase | On demand | Re-run the job with cache.refresh: true when the owner asks |
| Recently submitted sites | Once, then monthly | First capture on submission, then join the monthly run |
Two limits shape this plan. The CDN link in X-Cache-URL lives only until X-Cache-Expires, at most 30 days, so long-lived thumbnails belong in your own bucket; custom storage can upload the master there for you on paid plans. And a failed render costs nothing, so retry a timeout or navigation_failed the next night instead of in a tight loop. The caching reference covers the TTL rules.
What it costs
A search product that indexes 5,000 sites and refreshes them every 30 days makes 5,000 renders a month with the one-capture approach. That is inside the Growth plan's 12,000 credits for $40 a month, about $0.0080 per site per month. The four-request approach would need 20,000 renders for the same set, which moves you to Scale at $100. Prices come from the pricing page.
Next steps
For tall captures of whole pages, see design inspiration galleries. Then get a free API key and run the job against your own list. The free plan includes 150 credits every month.