Link previews (those little cards with a title, image, and description) make any list of URLs dramatically more scannable. But you can't build them purely client-side: fetching another site's HTML from the browser is blocked by CORS, and even if it weren't, you'd be shipping a full HTML parser to every visitor.
You need something server-side. Here are three ways to do it in a React app, from zero-backend to full DIY.
Option 1: Zero backend — call a metadata API from the client
If your app has no server (static SPA, GitHub Pages, etc.), use a metadata extraction API that supports CORS. I built LinkPeek for exactly this — it returns title, description, image, favicon, site name, OpenGraph and Twitter Card data as JSON.
import { useEffect, useState } from "react";
function LinkCard({ url }) {
const [meta, setMeta] = useState(null);
useEffect(() => {
let alive = true;
fetch(`https://linkpeek.dpears.workers.dev/v1/preview?url=${encodeURIComponent(url)}`)
.then(r => r.json())
.then(data => alive && setMeta(data))
.catch(() => alive && setMeta({ error: true }));
return () => { alive = false; };
}, [url]);
if (!meta) return <div className="link-card skeleton" />;
if (meta.error || !meta.title) return <a href={url}>{url}</a>;
return (
<a href={url} className="link-card" target="_blank" rel="noopener noreferrer">
{meta.image && <img src={meta.image} alt="" loading="lazy" />}
<div>
<strong>{meta.title}</strong>
<p>{meta.description?.slice(0, 140)}</p>
<small>
{meta.favicon && <img src={meta.favicon} width="14" height="14" alt="" />}
{meta.siteName ?? new URL(url).hostname}
</small>
</div>
</a>
);
}
With some card CSS (flex row, rounded corners, muted description text) this renders a Slack-style unfurl. The anonymous tier allows 25 requests a day per IP — enough for development. For production there's a free plan (500/month) and paid tiers via RapidAPI, plus a tiny typed client on npm:
npm i linkpeek-client
import { LinkPeek } from "linkpeek-client";
const lp = new LinkPeek({ rapidApiKey: process.env.RAPIDAPI_KEY });
const meta = await lp.preview("https://github.com");
Option 2: Next.js — fetch server-side, render static
If you're on Next.js, do the extraction at build/request time so the client ships zero fetch logic:
// app/components/LinkCard.jsx (Server Component)
export default async function LinkCard({ url }) {
const res = await fetch(
`https://linkpeek.dpears.workers.dev/v1/preview?url=${encodeURIComponent(url)}`,
{ next: { revalidate: 86400 } } // cache 24h
);
const meta = await res.json();
// ...same JSX as above, no useState/useEffect needed
}
Server components + revalidate give you cached previews with no client waterfall — the card is in the initial HTML, which also means it works in RSS readers and with JS disabled.
Option 3: Full DIY
Rolling your own is a fun weekend project. The core is simple — fetch the page, parse <meta property="og:*"> tags — but production hardening is where the time goes:
- Redirects & canonical URLs (short links, tracking wrappers)
- Bot blocking — many big sites serve challenge pages to datacenter IPs
-
Relative URLs in
og:imageand favicons that need resolving -
Fallback chains —
og:title→twitter:title→<title> -
SSRF protection — if you fetch user-supplied URLs, you MUST block
localhost, RFC-1918 ranges, and internal hostnames, or your preview endpoint is a proxy into your own infrastructure - Caching — you do not want to re-fetch a URL on every render
- Rate limiting — a public preview endpoint will be abused
I wrote up the sharpest edge I hit (distributed rate limiting on eventually-consistent storage) here, and the whole extractor is open source (MIT) if you want to read a working implementation: github.com/daviscodesbugs/linkpeek.
Which to pick?
| Situation | Pick |
|---|---|
| Static site / SPA, no backend | Option 1 (client fetch) |
| Next.js / Remix / server rendering | Option 2 (server fetch + cache) |
| Learning project, or very high volume | Option 3 (DIY) |
Questions welcome — happy to go deeper on any of these in the comments.