Link Preview Image Generation for Chat and Apps

Show a preview card for any link users paste: one request returns an 800x600 screenshot plus title, description and favicon, with caching and a fallback plan.

By Simon Chiu · Updated

A user pastes a link into your chat, CRM note or bookmark list. They expect what Slack and iMessage show: a card with a picture, a title and a line of description. Building that is called link unfurling, and most of it is easy until you meet real links: pages with no og:image, images that 404, sites that block your fetcher, and URLs that point at your own internal network.

This page shows a pipeline that handles those cases with one request per new link. It is one of our use cases. For social cards on your own pages, the OG image API is the better fit.

Why not only read og:image

Reading the linked page's og:image is the cheapest preview when it works. On 2026-10-18 we crawled the home pages of the top 10,000 domains for these tags, and the results are in the state of OG images:

  • 41.8% of readable home pages had neither an og:image nor a twitter:image.
  • 3.4% of the og:image URLs that existed did not load.
  • 19.3% of the images were square, mostly logos, which look poor in a wide card.
  • 758 of 10,000 home pages answered our honest crawler user agent with 403.

So a preview feature that relies on og:image alone shows a bare link for close to half of pasted URLs. A screenshot of the page is the fallback that always has something to show.

The request

The link_preview preset sets an 800x600 viewport, a 4:3 frame that fits chat bubbles and list rows better than a 1.91:1 social card. With Accept: application/json, the response holds a CDN URL for the image and the page metadata, so one call fills the whole card.

{
  "url": "https://example.com/",
  "preset": "link_preview",
  "output": { "format": "webp", "quality": 80 },
  "block": { "cookie_banners": true, "chat_widgets": true },
  "wait": { "timeout": 15 },
  "cache": { "available": true, "ttl": 2592000 }
}

The response you read:

{
  "image": { "url": "https://cdn.renderscreenshot.com/...", "width": 800, "height": 600, "format": "webp" },
  "metadata": { "title": "Example Domain", "description": null, "favicon": null },
  "cache": { "hit": false, "expires_at": "..." }
}

Request variants

Each row changes one thing from the request above. We describe them rather than show images; this page was written without calls to the live API.

Goal Change Why
Smallest file for chat lists Keep "format": "webp", "quality": 80 Photos and gradients shrink most; text stays readable at 800 px
Crisp preview on high-density screens Add "viewport": {"scale": 2} 1600x1200 pixels: four times the pixels to store and send
Match a dark chat theme Add "browser": {"dark_mode": true} Sites that support prefers-color-scheme render dark
Cleaner page "block": {"ads": true, "cookie_banners": true} Consent dialogs and ads cover the top of many pages
Mobile look Replace the preset with "viewport": {"device": "iphone_14_pro"} For apps where the preview opens on phones
Thumbnail for a dense list Add "output": {"resize": {"width": 400, "height": 300, "fit": "cover"}} Same layout, a quarter of the pixels; see image resize

How it works

flowchart LR
  U[User pastes a link] --> A[Your API]
  A --> D{Preview in your DB<br/>and not expired?}
  D -- yes --> R[Return stored card]
  D -- no --> Q[Background job]
  Q --> RS[POST /v1/screenshot<br/>preset link_preview<br/>cache.available]
  RS -- 200 --> S[Store title, description,<br/>favicon, image URL, expires_at]
  RS -- 4xx/422 --> F[Store fallback card:<br/>domain + favicon]
  S --> P[Push card to clients]
  F --> P

Two choices make this work:

  1. Render in a background job. A capture takes seconds. Send the message at once with a plain link, then update it when the card is ready. Chat users are used to cards that appear a moment later.
  2. Cache twice. Your database keeps the card per normalized URL, so a link pasted 1,000 times is rendered once. cache.available adds a second layer: if your job renders the same URL with the same options inside the TTL, the API returns the stored image for 0 credits. See caching.

The job, in Node

This function builds a card for one URL. It strips utm_ parameters and the fragment so that shared copies of one link hit the same cache entry, and it turns any API error into a fallback card instead of an exception.

// preview.mjs. Env: RS_API_KEY. Node 22.
const BASE = 'https://api.renderscreenshot.com';
const THIRTY_DAYS = 30 * 24 * 60 * 60;

export function normalize(input) {
  const u = new URL(input);
  if (!['http:', 'https:'].includes(u.protocol)) throw new Error('only http(s) links get previews');
  u.hash = '';
  for (const p of [...u.searchParams.keys()]) if (p.startsWith('utm_')) u.searchParams.delete(p);
  return u.toString();
}

export async function buildPreview(input) {
  const url = normalize(input);
  const res = await fetch(`${BASE}/v1/screenshot`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.RS_API_KEY}`,
      'Content-Type': 'application/json',
      Accept: 'application/json', // JSON with a CDN URL instead of image bytes
    },
    body: JSON.stringify({
      url,
      preset: 'link_preview',                       // 800x600
      output: { format: 'webp', quality: 80 },
      block: { cookie_banners: true, chat_widgets: true },
      wait: { timeout: 15 },
      cache: { available: true, ttl: THIRTY_DAYS }, // same URL within 30 days: 0 credits
    }),
    signal: AbortSignal.timeout(20_000),
  });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) {
    // A target_error (422) means the site failed to load: store the fallback card, retry later.
    return { url, image: null, title: null, description: null, favicon: null, error: body.error?.code ?? `http_${res.status}` };
  }
  return {
    url,
    image: body.image?.url ?? null,
    expiresAt: body.cache?.expires_at ?? null,
    title: body.metadata?.title ?? new URL(url).hostname,
    description: body.metadata?.description ?? null,
    favicon: body.metadata?.favicon ?? null,
    error: null,
  };
}

We ran it on 2026-10-18 (Node 22.18.0) against a local stub that returns the documented JSON. It sent the request body shown above, mapped a success to a card, mapped a 422 timeout to a fallback card with error: "timeout", and rejected a javascript: link before any request.

Call buildPreview from your job runner (BullMQ, Sidekiq, Celery or a plain queue table), save the result, and push it to clients. When expiresAt passes, the image URL stops working: re-run the job on the next view, or copy the file to your bucket with the storage option on paid plans.

The fallback card

Some links will fail: the site is down, blocks automated browsers, or times out. Errors in the target_error group return HTTP 422 and carry a retryable flag; the error reference lists them. For a preview, do not block the message on a retry:

  • Show the domain and, if you have it, the favicon.
  • Retry once in the background for retryable errors such as timeout and navigation_failed.
  • Do not retry blocked. Either the API refused the URL (for example a private or internal address), or the site blocked the renderer. The same request fails the same way again.

Settings that matter for previews

  • Preset link_preview (800x600) rather than og_card. A 4:3 frame shows more of the page's first screen and suits narrow chat columns.
  • WebP at quality 80. Previews are small on screen, so a lossy format is fine and lists load faster.
  • TTL of 30 days, the maximum. Pages change, but a preview is a hint, not a record.
  • wait.timeout of 15 seconds. A preview that takes longer is not worth the wait; fall back instead.
  • Block cookie banners and chat widgets. Otherwise many previews show a consent dialog instead of the page.

What it costs

You pay one credit for each new link that renders; cache hits cost nothing. A team chat product where users paste 10,000 distinct links a month needs about 10,000 credits. The Growth plan includes 12,000 credits for $40 a month, which works out to $0.0040 per preview at that volume. An app with only a few new links a day fits the Free plan's 150 monthly credits; a few hundred a month fits Starter (2,500 credits for $10). If you read og:image first and render only when it is missing, you pay for fewer renders. Current plans are on the pricing page.

Start

Add the job, paste a link, and watch the card arrive. Get a free API key to try it with your own links.

Last tested: 2026-10-18. Request format checked against the API reference; the job function tested against a local stub with Node 22.18.0, not the live API.

Frequently asked questions

Why not just use the og:image of the linked page?
Use it when it exists and loads. In our crawl of 10,000 home pages, 42% had no og:image or twitter:image at all, and 3.4% of the og:image URLs that did exist were broken. A screenshot covers those links, and it always shows the page as it looks today.
Does the API return the page title and description too?
Yes. Every successful response includes the page title, meta description and favicon URL: in X-Screenshot-Title, X-Screenshot-Description and X-Screenshot-Favicon headers, or in the metadata object of a JSON response. A value the page does not have is null in JSON and its header is left out.
Is it safe to send URLs that users paste?
The renderer checks every request the page makes against an SSRF policy and blocks private, loopback, link-local and metadata addresses. Your server never fetches the user's URL itself in this design. Still accept only http and https links, and limit how many previews one user can request.
How long can I keep the preview image URL?
Until the cache TTL ends, at most 30 days, as given in cache.expires_at. Store the metadata in your database and refresh the image when it expires, or copy the file to your own bucket.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.