Link previews — those little cards that show a title, description, and image when you paste a URL — are a great UX touch for chat apps, social feeds, or any interface where users share links.
In this tutorial, we will build a reusable React component that fetches and displays link previews.
The Problem
Browsers block cross-origin requests for security reasons. You cannot fetch https://github.com from your React app and read its <meta> tags directly — CORS will stop you.
The solution: a server-side proxy that fetches the URL, parses the HTML, and returns structured metadata.
Building the Hook
Let us start with a useLinkPreview hook:
import { useState, useEffect } from 'react';
function useLinkPreview(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/preview?url=${encodeURIComponent(url)}`, {
signal: controller.signal,
})
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch preview');
return res.json();
})
.then((json) => {
setData(json);
setLoading(false);
})
.catch((err) => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
The AbortController cancels in-flight requests when the URL changes or the component unmounts — important to avoid stale state updates.
The Preview Component
function LinkPreview({ url }) {
const { data, loading, error } = useLinkPreview(url);
if (!url) return null;
if (loading) {
return (
<div className="link-preview link-preview--loading">
<div className="skeleton skeleton--image" />
<div className="skeleton-lines">
<div className="skeleton skeleton--title" />
<div className="skeleton skeleton--desc" />
</div>
</div>
);
}
if (error || !data) return null;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="link-preview"
>
{data.image && (
<img
src={data.image}
alt={data.title || ''}
className="link-preview__image"
onError={(e) => { e.target.style.display = 'none'; }}
/>
)}
<div className="link-preview__body">
<p className="link-preview__title">{data.title}</p>
{data.description && (
<p className="link-preview__desc">{data.description}</p>
)}
<span className="link-preview__domain">{new URL(url).hostname}</span>
</div>
</a>
);
}
CSS Styling
.link-preview {
display: flex;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
text-decoration: none;
color: inherit;
max-width: 480px;
transition: box-shadow 0.2s;
}
.link-preview:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.link-preview__image {
width: 120px;
min-height: 80px;
object-fit: cover;
flex-shrink: 0;
}
.link-preview__body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.link-preview__title {
font-weight: 600;
font-size: 14px;
margin: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.link-preview__desc {
font-size: 12px;
color: #64748b;
margin: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.link-preview__domain {
font-size: 11px;
color: #94a3b8;
margin-top: auto;
}
/* Skeleton loading */
.skeleton {
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton--image { width: 120px; min-height: 80px; }
.skeleton--title { height: 14px; width: 80%; margin-bottom: 8px; }
.skeleton--desc { height: 12px; width: 60%; }
Setting Up the API Route
If you are using Next.js, create pages/api/preview.js:
export default async function handler(req, res) {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'url is required' });
try {
const response = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; LinkPreviewBot/1.0)' },
redirect: 'follow',
});
const html = await response.text();
const getMeta = (name) => {
const match =
html.match(new RegExp(`<meta[^>]+(?:property|name)=["']${name}["'][^>]+content=["']([^"']+)["']`, 'i')) ||
html.match(new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${name}["']`, 'i'));
return match ? match[1] : null;
};
const title =
getMeta('og:title') ||
(html.match(/<title>([^<]+)<\/title>/i) || [])[1] ||
'';
res.json({
title: title.trim(),
description: getMeta('og:description') || getMeta('description') || '',
image: getMeta('og:image') || '',
favicon: `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}`,
});
} catch (err) {
res.status(500).json({ error: 'Failed to fetch URL' });
}
}
Using It in Your App
function ChatMessage({ message }) {
const urlMatch = message.text.match(/https?:\/\/[^\s]+/);
const url = urlMatch ? urlMatch[0] : null;
return (
<div className="message">
<p>{message.text}</p>
{url && <LinkPreview url={url} />}
</div>
);
}
Performance Tips
1. Debounce with useDeferredValue. If users type URLs, avoid fetching on every keystroke:
import { useDeferredValue } from 'react';
const deferredUrl = useDeferredValue(inputUrl);
const { data } = useLinkPreview(deferredUrl);
2. Cache with SWR. Avoid re-fetching the same URL:
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then((r) => r.json());
function useLinkPreview(url) {
const apiUrl = url ? `/api/preview?url=${encodeURIComponent(url)}` : null;
return useSWR(apiUrl, fetcher, { revalidateOnFocus: false });
}
3. Validate URLs before fetching:
function isValidUrl(str) {
try {
const u = new URL(str);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
Skip Building the Backend
Writing the metadata parser yourself is fine for personal projects, but there are edge cases: relative image URLs, charset encoding, redirect chains, Twitter-specific tags, and more.
If you want to skip the server work, LinkPeek is a free API that handles all of this. The free tier gives you 100 requests/day — enough for most side projects:
const res = await fetch(
`https://linkpeek-api.linkpeek.workers.dev/v1/preview?url=${encodeURIComponent(url)}&key=YOUR_KEY`
);
const data = await res.json();
Wrapping Up
A link preview component is a small feature with a big UX impact. The key pieces:
- A server-side proxy to bypass CORS
- Graceful loading states with skeleton UI
- Error handling that fails silently (no ugly broken cards)
- Caching to avoid redundant fetches
Now go build something your users will actually enjoy using.
Top comments (0)