Skip to content
guide

How to build link previews that don't break

Reading og:title takes an hour. Handling the web that doesn't have it takes a week. The fallback chains, redirect handling and caching strategy that make unfurling actually work.

Link unfurling is a feature everyone estimates at an afternoon. Read og:title, og:description, og:image, render a card. It genuinely does take an afternoon — and then you ship it and a large share of your cards come back empty.

The work is not reading the tags. It is everything the web does that the tags do not cover.

Every field needs a fallback chain

A meaningful portion of the web has incomplete OpenGraph markup. Some sites have Twitter card tags but no OpenGraph. Some have neither and only a <title>. A service that reads only og: returns nothing for all of them.

The chain that covers most of the real web:

FieldOrder to try
Titleog:titletwitter:title<title>
Descriptionog:descriptiontwitter:description<meta name="description">
Imageog:imageog:image:urltwitter:image
Site nameog:site_nameapplication-name → hostname
Favicondeclared <link rel="icon">/favicon.ico

That last fallback matters more than it looks. A surprising number of sites declare no icon at all, and nearly all of them still serve the conventional path.

Note also that meta tags appear in both attribute orders — property before content and the reverse — so a regex matching only one shape silently misses half the web.

Resolve URLs against the final URL

This is the bug that reaches production most often, because it works in testing.

An image referenced as /og/cover.png resolves fine while you are looking at the page. The moment your card renders anywhere else, it is a broken image.

Resolve every URL to absolute — and resolve it against the URL you ended at, not the one you were given:

const absolute = (href: string, finalUrl: string): string | null => {
  try { return new URL(href, finalUrl).toString() } catch { return null }
}

Follow redirects, and keep the destination

Link shorteners are most of what people actually paste. If you store the submitted URL rather than the final one, you end up with a preview whose URL points somewhere different from the content it describes — and every later cache lookup keys on the wrong thing.

Follow redirects, cap the hops, and return the destination as part of the response. Store that.

There is a security dimension here too. If you fetch user-supplied URLs from your own server, you have built a server-side request forgery surface. Someone will eventually submit a URL that resolves to 169.254.169.254 and read your cloud credentials, or point it at an internal address to map your private network using your IP and your trust.

Two rules make it safe, and you need both:

  1. Resolve DNS and check the resolved addresses, not the hostname. Checking the hostname is defeated by an attacker-controlled domain that simply resolves to 127.0.0.1.
  2. Re-check every redirect hop. A public URL that 302s to a private one walks straight past a check performed only on the first request.

Cache on your side, by URL

Page metadata changes rarely. Whether you fetch it yourself or call a service, this is the difference between one lookup per link and one per impression — which is the difference between a rounding error and a real bill, in egress and latency if not in money.

async function preview(url: string) {
  const hit = await cache.get(url)
  if (hit) return hit

  const data = await unfurl(url)
  await cache.set(url, data, { ttl: "7d" })
  return data
}

Key on the final URL where you have it, so two shorteners pointing at the same destination share one entry. And cache failures too, briefly — otherwise one dead link gets retried on every render.

The smaller things that cost a day each

  • Some sites serve different markup to non-browser user agents. Identify yourself honestly; sites that want to refuse automated traffic are entitled to, and pretending to be a browser to evade that is a choice you should make deliberately rather than by default.
  • A few sites put the only usable description in JSON-LD rather than a meta tag.
  • Cap the response size. A page that streams indefinitely will otherwise hold a connection and consume memory until something gives.
  • Set a timeout. A slow site should not be able to hold your render path open.

When you need the content, not the card

Unfurling gives you what a link is about. If you need what it actually says — to summarise it, index it, or feed it to a model — that is a different job, and content extraction is the tool for it. Reaching for a preview service to get article text gets you a 160-character description and nothing else.

The short version

Fallback chains for every field. Resolve against the final URL. Follow redirects and store where you landed. Validate resolved addresses on every hop. Cache by URL on your side.

None of it is hard. There is just far more of it than the afternoon you budgeted.

Rendering the card without ruining the page

The metadata is the easy half. The card itself introduces three problems that only appear once real links are flowing through it.

Never hotlink the image. Rendering the remote og:image directly means your page's load time is set by someone else's server, your users' IP addresses are disclosed to every site anyone links to, and the card breaks the day that URL moves. Proxy it through your own resizing endpoint, cache the result, and serve a size that matches the slot — OpenGraph images are typically 1200×630 and are routinely rendered into a 400-pixel-wide card, which is roughly nine times the bytes needed.

Reserve the space. A card that pops into existence when the image loads shifts everything below it, which is both unpleasant and a measurable ranking signal. Set an explicit aspect ratio on the image container so the layout is stable before anything arrives, and lazy-load images below the fold.

Design for the missing case first. A meaningful share of links will have no image, and some will have no description. If the card only looks right fully populated, the ones that matter — documentation, forum threads, older pages — will look broken. Build the title-and-domain-only version first and treat the image as an enhancement.

Two smaller things worth doing once: use the site name and favicon to show the source, since a domain is what people actually check before clicking; and put the page title in the image's alt text rather than leaving it empty, so the card means something to a screen reader.

What you can build with this

A bookmarking tool that looks finished. The difference between a list of blue links and something you enjoy opening is entirely the cards. Unfurl on save, cache the result, and you have the whole feature.

Rich links in your own chat, comments or notes app. Paste a URL, get a card. Worth doing well because it is the most-seen surface in the product: everyone sees it every day, and a broken card is more noticeable than almost any other bug.

A link health checker. You are already fetching every link and following its redirects, so record the final status. Run it over your site's outbound links monthly and you have a dead-link report that costs nothing extra to produce. Dead outbound links are a slow, invisible quality problem on any site with archives.

A social preview debugger. Show what a page's card will look like on each platform, alongside which fields fell back and which are missing. Genuinely useful to anyone shipping a site, and unlike most side projects, people will find it by searching for exactly what it does.

Frequently asked

Should I display the remote og:image directly?+

No. Proxy and resize it through your own endpoint. Hotlinking ties your page's load time to someone else's server, discloses your users' IP addresses to every linked site, and breaks whenever that URL moves — and OpenGraph images are usually far larger than the card that displays them.

What should a card look like when there is no image?+

Title, description and domain, laid out so it still looks deliberate. Build that version first: a large share of real links have no image, and a card designed only for the fully populated case makes the rest look broken.

Why do so many pages have no OpenGraph tags?+

OpenGraph is a convention, not a requirement, and plenty of the web predates it or was built without it. Documentation sites, forums and older publishers frequently have only a title and a description, which is why the fallback chain does most of the work.

How long should I cache a preview?+

Days rather than minutes for most links. Page metadata changes rarely, and a stale title is a far smaller problem than a fetch on every render. Cache failures for minutes so a dead link is not retried constantly.

Is fetching user-supplied URLs dangerous?+

Yes, if you do not guard it. It is a server-side request forgery surface: resolve DNS and check the resolved addresses against private, loopback and link-local ranges, and re-check on every redirect hop. Checking the hostname alone is not sufficient.