Website Thumbnail Generator API

Generate website thumbnails from any URL in four sizes from one capture, with a placeholder pattern, a 30-day cache plan and a tested Node script.

By Simon Chiu · Updated

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.

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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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.

Frequently asked questions

Should I call the screenshot API from the page that shows the thumbnail?
No. A capture takes seconds, so render the listing at once with a placeholder and make the capture in a background job. Show the thumbnail on the next page view, or swap it in when the job finishes.
Do I pay again when two users submit the same URL?
Not if the request sets cache.available. An identical render from your account inside the cache TTL comes back for 0 credits, with X-Cache-Hit set to true.
Can a signed URL in an img tag return a small thumbnail?
It returns an image at the viewport size you sign, because output.resize is a POST option. The browser then scales it down. That works for a few images on a page, but a grid of 50 full-size captures is heavy, so use a job and serve resized files instead.
How long does the X-Cache-URL link last?
Until X-Cache-Expires, which is the render time plus cache.ttl. The longest TTL is 30 days. For a link that never expires, keep the file in your own storage.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.