GuidesLINK PREVIEWS

Build link previews in JavaScript without a backend

A link preview is the title, description and image a page declares in its Open Graph meta tags. Browser JavaScript cannot fetch another site directly because almost no site sends Access-Control-Allow-Origin, so fetch the page through https://proxy.cors.dev/ followed by its URL, parse the HTML with DOMParser and read og:title, og:description and og:image. The free tier needs no signup and no API key and relays HTML pages up to 1 MiB within 10 seconds; the whole preview is about 40 lines of code and zero servers.

What a link preview reads

Messaging apps and social networks build their cards from the same tags, so most sites maintain them. Read Open Graph first, Twitter card tags second, and plain HTML last; relative URLs in og:image or the icon must be resolved against the page URL with new URL(href, pageUrl).

Where each preview field comes from
FieldFirst choiceFallbacks
Titlemeta property="og:title"meta name="twitter:title", then the title element
Descriptionmeta property="og:description"meta name="twitter:description", then meta name="description"
Imagemeta property="og:image"meta name="twitter:image"; resolve relative URLs
Canonical URLlink rel="canonical"The URL you fetched
Site namemeta property="og:site_name"The hostname
Iconlink rel="icon" or rel="shortcut icon"/favicon.ico on the same origin

Fetch the page and parse the tags

The browser blocks a direct fetch() of another site because the response has no Access-Control-Allow-Origin header; a request through the proxy comes back with that header set to your origin, the upstream status, and the HTML unchanged. DOMParser with text/html parses it without running scripts or loading images, so reading tags from untrusted HTML is safe. Set credentials: 'omit': the proxy never forwards cookies anyway, and this keeps the request simple.

getLinkPreview(): fetch the page and read its Open Graph tags
const PROXY = 'https://proxy.cors.dev/';

async function getLinkPreview(pageUrl) {
  const response = await fetch(PROXY + pageUrl, { credentials: 'omit' });
  if (!response.ok) {
    throw new Error(`Preview failed: ${response.headers.get('X-Cors-Error') ?? response.status}`);
  }

  const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
  const meta = (selector) => doc.querySelector(selector)?.getAttribute('content')?.trim() || '';
  const absolute = (href) => (href ? new URL(href, pageUrl).href : '');

  return {
    url: absolute(doc.querySelector('link[rel="canonical"]')?.getAttribute('href') || pageUrl),
    title: meta('meta[property="og:title"]') || meta('meta[name="twitter:title"]') || doc.title.trim(),
    description:
      meta('meta[property="og:description"]') ||
      meta('meta[name="twitter:description"]') ||
      meta('meta[name="description"]'),
    image: absolute(meta('meta[property="og:image"]') || meta('meta[name="twitter:image"]')),
    siteName: meta('meta[property="og:site_name"]') || new URL(pageUrl).hostname,
    icon: absolute(doc.querySelector('link[rel~="icon"]')?.getAttribute('href') || '/favicon.ico'),
  };
}

const preview = await getLinkPreview('https://github.blog/');
console.log(preview.title, preview.image);

When verified for this page, https://github.blog/ came back as 285 KB of HTML and https://flutter.dev/ as 74 KB, both well under the 1 MiB limit. A page that is too large answers 502 with X-Cors-Error: response_too_large; a page that is slow answers 502 with upstream_timeout after 10 seconds, and the 404 or 403 a site itself returns arrives unchanged with X-Cors-Source: upstream.

Render the card

Everything you read came from someone else's page, so set text with textContent and build elements with DOM methods instead of innerHTML. The image needs no proxy at all: an img element loads cross-origin images without CORS, and referrerpolicy="no-referrer" avoids hotlink checks that key on the referrer.

Render the preview card without innerHTML
// Markup: <a id="preview" class="preview" target="_blank" rel="noopener"></a>
function renderPreview(card, preview) {
  card.href = preview.url;
  card.replaceChildren();

  if (preview.image) {
    const image = document.createElement('img');
    image.src = preview.image; // images load cross-origin without CORS
    image.alt = '';
    image.loading = 'lazy';
    image.referrerPolicy = 'no-referrer';
    card.append(image);
  }

  const title = document.createElement('strong');
  title.textContent = preview.title || preview.url;

  const description = document.createElement('p');
  description.textContent = preview.description;

  const site = document.createElement('small');
  site.textContent = preview.siteName;

  card.append(title, description, site);
}

getLinkPreview('https://github.blog/')
  .then((preview) => renderPreview(document.querySelector('#preview'), preview))
  .catch((error) => {
    document.querySelector('#preview').textContent = error.message;
  });

Pages that will not preview well

  • Client-rendered apps: when the HTML is an empty shell filled in by JavaScript, the tags are not in the response. The proxy relays what the server sent and runs nothing.
  • Login walls and bot checks: the proxy sends no cookies, so you get the logged-out page, and a site that answers 403 to unknown clients answers 403 through the proxy as well.
  • Sites over 1 MiB: large news home pages approach the free limit. Pro relays up to 6 MiB. Article pages are usually far smaller than home pages.
  • Plain http:// URLs: the proxy accepts public HTTPS targets only. Try the https:// form of the same URL first.
  • Domains that opted out: a site owner can block proxy access, and you receive 403 with X-Cors-Error: owner_opted_out.

Limits and pricing

cors.dev limits for link previews
LimitFree, no keyPro, $5 per month
Price$0$5 per month; 7-day trial with 1,000 requests, no card
MethodsGET and HEADGET, HEAD, POST, PUT, PATCH and DELETE
DestinationsAny public HTTPS hostPublic HTTPS hosts enabled on your Connection
Response size1 MiB, text types only6 MiB, any content type
Upstream deadline10 seconds10 seconds
RateShared pool with fair-use limits600 requests per minute, 10 concurrent per account
Monthly requestsNo quota500,000 per billing period, $5 per extra 500,000
RedirectsFollowed, up to 4 hopsFollowed when every hop host is enabled
CachingNone, no-storeOpt-in X-Cors-Cache, 1 to 300 seconds
Cookies forwardedNeverNever

When a preview API or your own server is better

The browser approach costs nothing and ships today, and it previews the page as an anonymous visitor sees it. Reach for a server when you need screenshots, previews of pages that render client-side, caching shared between all your users, or previews generated while nobody has the page open, for example inside a chat message stored on your backend. Hosted preview APIs do the same work for a fee and an API key that must not live in your frontend.

Good questions.

Why can I not fetch the page directly from my JavaScript?

Reading a cross-origin response requires an Access-Control-Allow-Origin header from that site, and regular web pages do not send one. The proxy fetches the page server-side, where the rule does not apply, and adds the header for your origin.

Do I need a proxy for the og:image too?

No. Images referenced by an img element load without CORS checks; only JavaScript reading of the bytes would need them. Load the image URL directly and add referrerpolicy="no-referrer".

Can I cache previews?

In the browser, store the parsed preview in localStorage keyed by URL and refresh it after a day. On Pro, X-Cors-Cache: 300 caches the proxied page for 5 minutes so repeated previews of the same URL do not hit the site again.

Why does a site return no tags through the proxy but shows a card in Slack?

Some sites serve full tags only to known crawler user agents or render them with JavaScript. The proxy requests the page as a regular client and relays the HTML it receives, so those pages come back with a title at best.

What about pages larger than 1 MiB?

The free tier answers 502 with X-Cors-Error: response_too_large. Pro relays up to 6 MiB. The meta tags sit in the head, but HTTP has no way to request only the first part of a page unless the server supports ranges, so the whole document counts.

Is this allowed?

You are fetching public pages the way any browser, feed reader or messaging app does, without cookies. Respect the response you get: a 403 or an opt-out means the owner does not want it, and the proxy will not help you around that.