Link Unfurling: How Chat Apps Build Link Previews

Link unfurling turns a pasted URL into a preview card. See what an unfurler reads, why tags added by JavaScript are missed, and tested code to check a page.

By Simon Chiu · Updated

Link unfurling is the process in which a chat app, social network or other client fetches a URL that someone shared, reads the page's metadata (Open Graph tags, Twitter card tags, oEmbed or the plain title), and shows the result as a preview card with a title, description and image.

Slack, Discord, Microsoft Teams, iMessage, WhatsApp, LinkedIn and Mastodon all unfurl links, each with its own rules. This entry is part of the screenshot and rendering glossary. For the product side, see link previews.

A plain explanation

When you paste a link, the app does not show you the page. Its server (or sometimes your device) downloads the page's HTML, looks for a few known tags, downloads the image those tags point to, and draws a card. If the tags are missing, the card falls back to the page title, or the app shows a bare link.

The person who shares the link controls nothing here. The page owner does: the tags in the HTML decide what every unfurl shows.

What an unfurler sees

We wrote a short unfurler in Node (below): it fetches the HTML without running JavaScript, reads og:* tags, and falls back to <title>. Then we ran it on three pages.

Page What the unfurler read
https://example.com Title "Example Domain". No description, no image.
Local page with OG tags in the HTML Title, description, image URL and an oEmbed link
Local single-page app that adds the same tags with JavaScript Title "Loading…". No description, no image.
Two generic preview cards built from the unfurler output. Left, for the page with tags in the HTML: title Static OG tags, a description and a large dark blue card image. Right, for the single-page app: title Loading, no description and a dashed box saying no og:image found.
Cards drawn from the unfurler's output for the two local pages, rendered with Puppeteer 25.14.0 on 2026-10-15. The card design is generic, not any app's real layout.

The single-page app had all four tags once its script ran: Puppeteer read og:title as "Tags added by JavaScript". The unfurler never ran that script, so it saw the placeholder title. This is the most common reason for a blank preview on a client-rendered site.

How it works technically

URL postedin a message Fetch HTMLbot user agent, no JS Parse metadataog:, twitter:, oEmbed Fetch image, cachedraw the card Each step has limits: size and time caps on the HTML, image size and format rules, cache lifetime.
The usual unfurl pipeline. Each platform sets its own limits.

The unfurler sends a request with its own bot user agent, follows redirects, and many unfurlers stop reading after a size limit, so tags belong early in <head>. It prefers Open Graph tags, then Twitter card tags, then the <title> and meta description. Some apps, Slack among them, also check for an oEmbed discovery link, which lets a provider return a richer embed. The image is a second request, so it must be publicly reachable, a format the app supports, and a sensible size: the OG image entry covers sizes. The result is cached, so a fix to your page does not change cards that already exist.

How to check it in Puppeteer, Playwright and RenderScreenshot

A plain unfurler (Node). This is what most platforms do: no browser, no JavaScript.

// A minimal unfurler: fetch the HTML (no JavaScript), read OG tags, fall back to <title>.
// Usage: node unfurl.mjs <url>
export async function unfurl(url) {
  const res = await fetch(url, { headers: { 'user-agent': 'ExampleUnfurler/1.0', accept: 'text/html' }, redirect: 'follow', signal: AbortSignal.timeout(5000) });
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
  const html = (await res.text()).slice(0, 500_000);
  const meta = {};
  for (const m of html.matchAll(/<meta\s+[^>]*>/gi)) {
    const prop = /(?:property|name)=["']([^"']+)["']/i.exec(m[0])?.[1];
    const content = /content=["']([^"']*)["']/i.exec(m[0])?.[1];
    if (prop && content !== undefined) meta[prop.toLowerCase()] = content;
  }
  const title = /<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim();
  const oembed = /<link\s+[^>]*type=["']application\/json\+oembed["'][^>]*>/i.exec(html)?.[0];
  return {
    title: meta['og:title'] ?? meta['twitter:title'] ?? title ?? url,
    description: meta['og:description'] ?? meta.description ?? null,
    image: meta['og:image'] ?? meta['twitter:image'] ?? null,
    oembed: oembed ? /href=["']([^"']+)["']/i.exec(oembed)?.[1].replaceAll('&amp;', '&') : null,
  };
}
if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(await unfurl(process.argv[2] ?? 'https://example.com'));
}

Playwright. Compare the raw HTML with the rendered DOM. Any tag that only exists after JavaScript is a tag unfurlers miss. The script exits with code 1 when it finds one, so it can run in CI.

// Compare the OG tags a crawler sees (raw HTML) with the tags in the rendered DOM.
import { chromium } from 'playwright';

const url = process.argv[2] ?? 'https://example.com';
const OG = /<meta\s+[^>]*property=["'](og:[^"']+)["'][^>]*>/gi;

const browser = await chromium.launch();
try {
  const page = await browser.newPage();
  const raw = await (await page.request.get(url)).text(); // no JavaScript, like an unfurler
  const inRaw = new Set([...raw.matchAll(OG)].map((m) => m[1]));
  await page.goto(url, { waitUntil: 'networkidle' });
  const inDom = await page.locator('meta[property^="og:"]').evaluateAll((ms) => ms.map((m) => m.getAttribute('property')));
  const jsOnly = inDom.filter((p) => !inRaw.has(p));
  console.log('in raw HTML:', [...inRaw].join(', ') || 'none');
  console.log('only after JavaScript:', jsOnly.join(', ') || 'none');
  if (jsOnly.length) process.exitCode = 1; // unfurlers will not see these
} finally {
  await browser.close();
}

Puppeteer. Read the rendered tags and capture a 1200 x 630 view of the page, the size most cards use.

// Render the page, read its OG tags, and capture what og:image would show.
import puppeteer from 'puppeteer';

const url = process.argv[2] ?? 'https://example.com';
const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1200, height: 630 }); // the usual og:image size
  await page.goto(url, { waitUntil: 'networkidle0' });
  const og = await page.$$eval('meta[property^="og:"]', (ms) =>
    Object.fromEntries(ms.map((m) => [m.getAttribute('property'), m.content])));
  console.log(Object.keys(og).length ? og : 'no og: tags in the rendered page');
  await page.screenshot({ path: 'card-1200x630.png' });
} finally {
  await browser.close();
}

RenderScreenshot. Give every page an og:image without designing one: point the tag at a signed URL with the og_card preset (1200 x 630). The unfurler fetches it like any image, and RenderScreenshot caches the rendered image, so repeat fetches do not render the page again. Put the tag in the server HTML, not in client JavaScript.

<meta property="og:image"
      content="https://api.renderscreenshot.com/v1/screenshot?expires=1792644628&key_id=rs_pub_...&preset=og_card&url=https%3A%2F%2Fexample.com%2Fblog%2Fmy-post&signature=...">

Build the URL on your server with the signing function from the signed URL docs; never put a secret key in the page.

Tested on 2026-10-15 with Node 22.18.0, Puppeteer 25.14.0 (Chrome 155) and Playwright 1.64.1 (Chromium 156), against https://example.com and the two local pages. The signed URL above is a shape example and was not fetched.

Common mistakes

  • Tags added by JavaScript. Most unfurlers do not run it. Render the tags on the server.
  • Relative image URLs. Use an absolute https:// URL in og:image.
  • Images behind auth or bot protection. The unfurler's image request must succeed without cookies.
  • Expecting instant updates. Old cards stay cached. Plan a refresh step per platform.

To test how your pages unfurl, try the link preview checker. To create card images for every page, get a free API key.

Frequently asked questions

Do link unfurlers run JavaScript?
Most do not. They fetch the raw HTML and read the meta tags in it. Tags that your JavaScript adds after load are invisible to them.
Why does my old link preview not update?
Platforms cache the unfurl result, often for days. Change the page, then use the platform's own refresh or debug tool, or change the URL, for example with a query string.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.