When users share links in your React app, showing a rich preview — title, description, image — makes the experience feel polished and professional. Think Slack, Notion, or Twitter.
In this guide, I'll show you two approaches: building it from scratch, and using an API to skip the hard parts.
Why Link Previews Are Tricky
To generate a preview, you need to fetch the target URL and parse its <meta> tags (Open Graph, Twitter Cards, etc.). Simple in theory, but:
- CORS blocks you — browsers won't let you fetch arbitrary URLs from the frontend
- JavaScript-rendered sites — many pages need a headless browser to get the right tags
- Performance — fetching external URLs on every render tanks UX
- Rate limiting — sites may block your scraper
The only real solution is a backend proxy.
Approach 1: Build Your Own Backend Proxy
Create a small Node.js endpoint that fetches the URL server-side and parses the meta tags:
// server.js (Express)
const express = require('express');
const axios = require('axios');
const cheerio = require('cheerio');
const app = express();
app.get('/api/preview', async (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'url required' });
try {
const { data } = await axios.get(url, {
headers: { 'User-Agent': 'Mozilla/5.0' },
timeout: 8000,
});
const $ = cheerio.load(data);
const get = (selector) => $(selector).attr('content') || '';
res.json({
title:
get('meta[property="og:title"]') ||
get('meta[name="twitter:title"]') ||
$('title').text(),
description:
get('meta[property="og:description"]') ||
get('meta[name="description"]'),
image:
get('meta[property="og:image"]') ||
get('meta[name="twitter:image"]'),
favicon: `${new URL(url).origin}/favicon.ico`,
});
} catch (err) {
res.status(500).json({ error: 'Failed to fetch URL' });
}
});
app.listen(3001);
Now your React component calls your own backend:
// LinkPreview.jsx
import { useState, useEffect } from 'react';
export function LinkPreview({ url }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/preview?url=${encodeURIComponent(url)}`)
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); });
}, [url]);
if (loading) return <div className="preview-skeleton" />;
if (!data?.title) return null;
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="link-preview">
{data.image && (
<img src={data.image} alt={data.title} className="preview-image" />
)}
<div className="preview-body">
<div className="preview-title">{data.title}</div>
{data.description && (
<div className="preview-desc">{data.description}</div>
)}
<div className="preview-domain">
{data.favicon && <img src={data.favicon} width={14} height={14} alt="" />}
{new URL(url).hostname}
</div>
</div>
</a>
);
}
Add some CSS:
.link-preview {
display: flex;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
text-decoration: none;
color: inherit;
max-width: 500px;
transition: box-shadow 0.2s;
}
.link-preview:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.preview-image { width: 120px; object-fit: cover; flex-shrink: 0; }
.preview-body { padding: 12px; }
.preview-title { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
.preview-desc {
font-size: 12px; color: #64748b; margin-bottom: 8px;
display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden;
}
.preview-domain { font-size: 11px; color: #94a3b8; display: flex; align-items: center; gap: 4px; }
.preview-skeleton { height: 80px; background: #f1f5f9; border-radius: 8px; animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:0.5 } }
The Problems With DIY
The Cheerio approach works for static sites. But you'll quickly hit:
- SPAs (React, Next.js apps) that render meta tags client-side — Cheerio sees empty tags
- Paywalled sites that block scrapers
- Timeouts on slow URLs that block your API response
- Maintenance — you now own a scraping service
For a side project or MVP this is fine. For production with high volume, you probably want to offload this.
Approach 2: Use a Link Preview API
Instead of maintaining your own scraper, you can call a dedicated API. Here's the same component using LinkPeek:
import { useState, useEffect } from 'react';
const LINKPEEK_KEY = process.env.REACT_APP_LINKPEEK_KEY;
export function LinkPreview({ url }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const apiUrl =
`https://linkpeek-api.linkpeek.workers.dev/v1/preview` +
`?url=${encodeURIComponent(url)}&key=${LINKPEEK_KEY}`;
fetch(apiUrl)
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); });
}, [url]);
if (loading) return <div className="preview-skeleton" />;
if (!data?.title) return null;
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="link-preview">
{data.image && <img src={data.image} alt={data.title} className="preview-image" />}
<div className="preview-body">
<div className="preview-title">{data.title}</div>
{data.description && <div className="preview-desc">{data.description}</div>}
<div className="preview-domain">
{data.favicon && <img src={data.favicon} width={14} height={14} alt="" />}
{new URL(url).hostname}
</div>
</div>
</a>
);
}
No backend needed — you call the API directly from React (CORS is handled). Free tier: 100 requests/day.
Handling Multiple URLs
If you need to preview a list of URLs (like a feed), cache results to avoid redundant requests:
import { useState, useEffect } from 'react';
// Simple in-memory cache
const cache = new Map();
export function useLinkPreview(url) {
const [data, setData] = useState(cache.get(url) || null);
const [loading, setLoading] = useState(!cache.has(url));
useEffect(() => {
if (cache.has(url)) return;
const controller = new AbortController();
const apiUrl =
`https://linkpeek-api.linkpeek.workers.dev/v1/preview` +
`?url=${encodeURIComponent(url)}&key=${process.env.REACT_APP_LINKPEEK_KEY}`;
fetch(apiUrl, { signal: controller.signal })
.then((r) => r.json())
.then((d) => { cache.set(url, d); setData(d); setLoading(false); })
.catch(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading };
}
Use it like:
function FeedItem({ url }) {
const { data, loading } = useLinkPreview(url);
// render...
}
Summary
| Approach | Pros | Cons |
|---|---|---|
| DIY Express proxy | Full control, no cost | Maintenance, misses SPAs, CORS setup |
| Link Preview API | Zero backend, handles edge cases | External dependency |
For most React apps, starting with a dedicated API is the pragmatic choice. If you outgrow it or need custom behavior, you can always build your own backend later.
Free API key at linkpeek-api.linkpeek.workers.dev — register with your email and get 100 requests/day instantly.
Questions about the implementation? Drop them in the comments.
Top comments (0)