How to Add Link Previews to Your React App (With Code Examples)
Source: Dev.to
Introduction
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 you’ll see two approaches:
- Build a preview service from scratch.
- Use a third‑party API to skip the hard parts.
Why a Backend Proxy Is Needed
To generate a preview you need to fetch the target URL and parse its meta tags (Open Graph, Twitter Cards, etc.). In theory this is simple, but in practice you run into:
- CORS – browsers block arbitrary URL fetches from the frontend.
- JavaScript‑rendered sites – many pages need a headless browser to expose the correct tags.
- Performance – fetching external URLs on every render hurts UX.
- Rate limiting – sites may block your scraper.
The reliable solution is a small backend proxy that performs the request server‑side.
DIY Express Proxy
// 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);React Component Using the Proxy
// 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 null; // or a skeleton component
if (!data?.title) return null;
return (
<a href={url} className="link-preview" target="_blank" rel="noopener noreferrer">
{data.image && <img src={data.image} alt="" 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} alt="favicon" width={16} height={16} />}
{new URL(url).hostname}
</div>
</div>
</a>
);
}CSS for the Preview
.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 }
}Limitations of the Cheerio Approach
- SPAs (React, Next.js) that render meta tags client‑side → Cheerio sees empty tags.
- Paywalled sites may block scrapers.
- Timeouts on slow URLs can delay your API response.
- Maintenance – you now own a scraping service.
For a side project or MVP this can be acceptable, but production workloads usually require a more robust solution.
Using a Dedicated Link Preview API (LinkPeek)
Instead of maintaining your own scraper, you can call a third‑party API directly from the frontend. CORS is handled by the service.
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 null; // or a skeleton component
if (!data?.title) return null;
return (
<a href={url} className="link-preview" target="_blank" rel="noopener noreferrer">
{data.image && <img src={data.image} alt="" 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} alt="favicon" width={16} height={16} />}
{new URL(url).hostname}
</div>
</div>
</a>
);
}- No backend needed – call the API directly.
- Free tier: 100 requests per day.
Caching Link Previews
When previewing many URLs (e.g., 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 };
}Using the Hook
function FeedItem({ url }) {
const { data, loading } = useLinkPreview(url);
// Render preview or loading state...
}Comparison
| Approach | Pros | Cons |
|---|---|---|
| DIY Express proxy | Full control, no external cost | Requires maintenance, misses SPA meta tags, CORS setup |
| Link Preview API | Zero backend, handles edge cases | Relies on external service |
For most React apps, starting with a dedicated API is the pragmatic choice. If you outgrow it or need custom behavior, you can later build your own backend.
Getting a Free API Key
Register at linkpeek-api.linkpeek.workers.dev with your email to receive 100 requests/day instantly.
Questions about the implementation? Drop them in the comments.