Keyword: link preview API · Updated October 12, 2026

Link Preview API: Build Rich Website Thumbnails for Any URL

Paste a link into Slack, Notion or Linear and it becomes a card with a title, a short description, a favicon and an image. Users expect the same in every product that accepts URLs. This guide shows how to build that with a link preview API that renders the page in a real browser, reads its metadata, and falls back to a screenshot when the site has no usable og:image. All of it comes from one SnapshotFlow request.

A pasted link turns into preview cards and appears as a rich link unfurl inside a chat window
A URL goes in, a rendered thumbnail and page metadata come out, and your app shows a preview card.

What is a link preview API?

A link preview API takes a URL and returns what you need to draw a preview card: title, description, favicon, domain and an image. Chat apps, bookmarking tools, CMS editors, CRMs and directories use it to turn pasted links into rich cards. The hard part is the image, because many pages do not declare a good one.

This is different from generating Open Graph images for your own pages, which we cover in dynamic OG image generation. Here the URLs belong to someone else. You do not control their markup, their JavaScript or their image choices, so the preview service has to cope with whatever it finds.

Why simple link previews break

The classic implementation fetches the HTML, parses <meta property="og:*"> tags from the Open Graph protocol, and shows whatever it finds. That works for large publishers and fails in four common cases:

  • No og:image at all. Documentation sites, internal tools, small business pages and many SaaS app routes ship without one. The card ends up as a grey box.
  • Metadata set by JavaScript. Single-page apps often write the title and meta tags after the bundle runs. A plain HTTP fetch sees the empty shell.
  • A generic or stale image. Every page of a site may share the same logo as og:image, so twenty different links look identical in a list.
  • Bot blocks and consent walls. Some sites return a challenge page or a cookie wall to non-browser clients, and the parser reads that instead of the content.

A screenshot fixes the image problem in every case: it shows what the page actually looks like. Rendering in a real browser fixes the JavaScript problem. You get both from the same render if the API returns metadata alongside the image.

Link preview approaches compared

Pick by what your links look like and how much infrastructure you want to run.
ApproachJS-rendered metadataImage when og:image is missingWhat you operate
Fetch HTML and parse tagsNoNoneA parser; fast and cheap
oEmbed endpointsProvider-definedProvider thumbnailPer-provider integrations (YouTube, Vimeo and similar)
Self-hosted headless ChromeYesScreenshotBrowsers, queues, memory limits, timeouts
SnapshotFlow screenshot + metadataYesScreenshot from the same requestOne HTTPS call and your own cache

A practical setup combines them. Use oEmbed for the handful of video and media providers that publish it, and send every other URL through the rendered path. If you are deciding between running Chrome yourself and an API, the Puppeteer alternative breakdown compares the operational cost.

One request returns the thumbnail and the metadata

Diagram: a pasted URL goes to SnapshotFlow with metadata enabled, the server picks og:image or the screenshot, and a preview card shows favicon, title, description and image
The page is rendered once. Your server decides between the publisher's og:image and the screenshot.

Add metadata=true to a screenshot request and use response_type=base64 to receive JSON instead of raw image bytes:

curl -G "https://api.snapshotflow.com/screenshot" \ -H "X-Api-Key: $SNAPSHOTFLOW_API_KEY" \ --data-urlencode "url=https://example.com/blog/launch" \ -d metadata=true \ -d response_type=base64 \ -d format=webp -d quality=80 \ -d width=1280 -d height=800 \ -d image_width=640 \ -d block_cookie_banners=true -d block_ads=true

The response contains the thumbnail as a data URL plus the fields a preview card needs:

{ "image": "data:image/webp;base64,UklGR...", "url": "https://example.com/blog/launch", "format": "webp", "metadata": { "title": "Launch week: what shipped | Example", "description": "Five releases, one changelog, and the numbers behind them.", "og_title": "Launch week: what shipped", "og_description": "Five releases, one changelog, and the numbers behind them.", "og_image": "https://example.com/images/launch-og.png", "favicon": "https://example.com/favicon.ico" } }

Metadata is read after JavaScript runs, so single-page apps return their real titles. image_width=640 resizes the capture while keeping its aspect ratio, which gives you a 640×400 thumbnail from a 1280×800 viewport. The page is rendered at desktop width, so the layout looks like what a visitor sees, but the file stays small.

Build the preview service in Node.js

The function below validates the URL, makes one SnapshotFlow request, picks the image, stores it in your own bucket and returns a preview object. The store interface is whatever you already use: S3, R2, GCS or a local disk in development.

// link-preview.ts import { createHash } from 'node:crypto' const API = process.env.SNAPSHOTFLOW_API_BASE_URL ?? 'https://api.snapshotflow.com' const KEY = process.env.SNAPSHOTFLOW_API_KEY! type Meta = { title?: string | null; description?: string | null og_title?: string | null; og_description?: string | null og_image?: string | null; favicon?: string | null } export type LinkPreview = { url: string; domain: string title: string; description: string image: string | null; imageSource: 'og' | 'screenshot' | 'none' favicon: string | null; fetchedAt: string } export interface ImageStore { put(key: string, body: Buffer, contentType: string): Promise<string> // public URL } export function normalizeUrl(raw: string): URL { const u = new URL(raw.trim()) if (!['http:', 'https:'].includes(u.protocol)) throw new Error('Unsupported URL') u.hash = '' for (const p of [...u.searchParams.keys()]) { if (p.startsWith('utm_') || p === 'fbclid' || p === 'gclid') u.searchParams.delete(p) } return u } export async function buildLinkPreview(raw: string, store: ImageStore): Promise<LinkPreview> { const url = normalizeUrl(raw) const id = createHash('sha256').update(url.href).digest('hex').slice(0, 32) const params = new URLSearchParams({ url: url.href, metadata: 'true', response_type: 'base64', format: 'webp', quality: '80', width: '1280', height: '800', image_width: '640', block_cookie_banners: 'true', block_ads: 'true', user_agent: 'AcmeLinkPreview/1.0 (+https://acme.example/bot)', }) const res = await fetch(`${API}/screenshot?${params}`, { headers: { 'X-Api-Key': KEY }, signal: AbortSignal.timeout(45_000), }) if (!res.ok) throw new Error(`SnapshotFlow ${res.status}: ${await res.text()}`) const data = (await res.json()) as { image: string; metadata?: Meta } const m = data.metadata ?? {} // 1) Prefer the publisher's og:image when it exists and downloads cleanly. let image: string | null = null let imageSource: LinkPreview['imageSource'] = 'none' const og = absolute(m.og_image, url) if (og) { const ogFile = await downloadImage(og) if (ogFile) { image = await store.put(`previews/${id}-og`, ogFile.body, ogFile.type) imageSource = 'og' } } // 2) Otherwise use the screenshot we already have. if (!image) { const shot = Buffer.from(data.image.split(',', 2)[1], 'base64') image = await store.put(`previews/${id}.webp`, shot, 'image/webp') imageSource = 'screenshot' } return { url: url.href, domain: url.hostname.replace(/^www\./, ''), title: clip(m.og_title || m.title || url.hostname, 120), description: clip(m.og_description || m.description || '', 240), image, imageSource, favicon: m.favicon ?? null, fetchedAt: new Date().toISOString(), } } function absolute(value: string | null | undefined, base: URL): string | null { if (!value) return null try { const u = new URL(value, base) return u.protocol === 'https:' ? u.href : null } catch { return null } } const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)

The og:image download needs its own guard, because that URL comes from a page you do not control and your server is the one fetching it:

// Download og:image with size, type and timeout limits. // Use an SSRF-safe agent (for example request-filtering-agent) in production // so a hostile og:image cannot point your server at internal addresses. async function downloadImage(src: string) { try { const r = await fetch(src, { redirect: 'follow', signal: AbortSignal.timeout(8_000) }) const type = r.headers.get('content-type') ?? '' const size = Number(r.headers.get('content-length') ?? 0) if (!r.ok || !type.startsWith('image/') || size > 5_000_000) return null const body = Buffer.from(await r.arrayBuffer()) return body.length > 0 && body.length <= 5_000_000 ? { body, type } : null } catch { return null } }

Some products prefer the screenshot even when og:image exists, for example a bookmarking app where every card should look like the page itself rather than a marketing banner. In that case skip step 1 and you never fetch third-party images at all.

Render the preview card

Keep the markup simple and accessible. The image is decorative when the title sits next to it, so an empty alt keeps screen readers from announcing the same title twice.

<a class="lp-card" href="{url}" rel="noopener nofollow ugc" target="_blank"> <img class="lp-img" src="{image}" alt="" width="640" height="400" loading="lazy"> <div class="lp-body"> <p class="lp-domain"><img src="{favicon}" alt="" width="16" height="16"> {domain}</p> <p class="lp-title">{title}</p> <p class="lp-desc">{description}</p> </div> </a> <style> .lp-card { display:block; max-width:420px; border:1px solid #e2e8f0; border-radius:14px; overflow:hidden; color:inherit; text-decoration:none; background:#fff; } .lp-img { display:block; width:100%; height:auto; aspect-ratio:16/10; object-fit:cover; object-position:top; background:#f1f5f9; } .lp-body { padding:12px 14px; } .lp-domain { font-size:13px; color:#64748b; display:flex; gap:6px; align-items:center; } .lp-title { font-weight:600; margin:4px 0; } .lp-desc { font-size:14px; color:#475569; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; } </style>

Escape every field before inserting it into HTML. Titles and descriptions come from third-party pages, and the ugc and nofollow rel values tell search engines the link was added by a user.

Thumbnail sizes for common preview layouts

Render at a realistic viewport and downscale with image_width. Capturing at thumbnail size directly would trigger the site's mobile or tablet layout, which is rarely what you want for a desktop card.

Suggested SnapshotFlow parameters per layout.
LayoutViewportimage_widthResult
Chat or comment unfurl1280 × 800640640 × 400 (16:10)
Open Graph style card1200 × 628600600 × 314 (≈1.91:1)
Bookmark or directory grid1024 × 768400400 × 300 (4:3)
Mobile app list390 × 844, viewport_mobile=true240Tall phone thumbnail

For retina screens, set image_width to about twice the card's CSS width: a 320 px card gets a 640 px thumbnail. WebP keeps those larger files light, and the responsive screenshot guide covers viewport choices in depth.

Caching, refresh and bulk imports

  • Cache on your side. SnapshotFlow skips its own capture cache when metadata=true, because titles and descriptions change. Store the preview JSON keyed by the normalized URL and reuse it. Several days is a sensible default TTL for most links.
  • Deduplicate in flight. When a link is posted in a busy channel, many clients ask for the same preview at once. Keep one pending promise per URL so you render it once.
  • Offer a refresh. Let the link owner re-fetch a preview after they fix their page. Refresh popular previews in the background before they expire.
  • Bulk imports. Importing thousands of bookmarks? Push URLs into a queue and process them with a fixed concurrency. If you only need thumbnails, the batch endpoint captures up to 10 URLs per call. For long-running jobs, async capture with webhooks frees your workers while pages render.
  • Handle failures gracefully. If the render times out, show a text-only card with the domain and favicon, and retry later. A 4xx response for an invalid URL will not improve on retry.

Security and privacy checklist

  • Server-side request forgery. Users can paste any URL, including internal ones. SnapshotFlow rejects private and loopback addresses for the page render. Your own og:image download is a second fetch, so protect it with an SSRF-safe HTTP agent as shown above. The OWASP SSRF prevention cheat sheet explains the attack.
  • Do not hotlink. A hotlinked og:image sends every viewer's IP address and referrer to a third-party server, and the card breaks when the publisher moves the file. Store a copy and serve it from your CDN.
  • Identify your bot. Set a descriptive user_agent with a contact URL. Slack documents its own unfurling bot the same way, and site owners appreciate knowing who is visiting.
  • Rate limit per user. Preview generation is triggered by user input, so cap requests per account to stop someone from using your app as a free rendering service.
  • Escape everything. Treat title, description and favicon URL as untrusted strings in your templates.

Where a link preview API fits

ProductWhat the preview doesImage choice
Chat, comments, communityUnfurls pasted links in messagesog:image first, screenshot fallback
Bookmarking and read-laterMakes saved links recognizable in a gridScreenshot, so every card shows the real page
CMS and docs editorsTurns a URL into an embed blockog:image first
CRM and sales toolsShows a lead's website next to the recordScreenshot of the homepage
Directories and marketplacesGives every listing a consistent thumbnailScreenshot at a fixed size

If you are building the screenshot features of a SaaS product more broadly, the screenshot API for SaaS guide covers monitoring, reports and in-app previews together.

FAQ

What is a link preview API?

It takes a URL and returns the title, description, favicon, domain and image needed for a preview card. Chat apps, bookmarking tools, CMS editors and CRMs use it to turn pasted links into rich cards.

What if a website has no og:image?

Use a screenshot of the rendered page. SnapshotFlow returns the screenshot and the metadata from the same request, so the fallback needs no extra call.

Should I hotlink the og:image URL?

No. Hotlinked images break when the publisher changes them and expose your users' IP addresses to third parties. Download once, store it, serve it from your CDN.

How long should link previews be cached?

Several days suits most links. Give the link owner a manual refresh and refresh popular previews in the background.

SnapshotFlow instructions: ship link previews in five steps

  1. Create a SnapshotFlow account and add SNAPSHOTFLOW_API_KEY to your server environment.
  2. Try the request in the playground with metadata=true and a few of your users' typical URLs.
  3. Copy buildLinkPreview, connect it to your storage, and decide whether og:image or the screenshot is your default image.
  4. Cache preview JSON by normalized URL, add per-user rate limits, and render the card template.
  5. Check the full parameter list, including wait_for_selector and hide_selectors, in the API reference.

Sources and further reading

Every pasted link deserves a real preview

Render once, read the metadata, keep a screenshot ready for the pages that never set an og:image.

Get an API key