How to Add Link Previews to Your React App (With Code Examples)

Published: (March 20, 2026 at 01:40 AM EDT)
5 min read
Source: Dev.to

Source: Dev.to

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. To generate a preview, you need to fetch the target URL and parse its 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. 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 ; if (!data?.title) return null;

return (

  {data.image && (
    
  )}
  
    {data.title}
    {data.description && (
      {data.description}
    )}
    
      {data.favicon && }
      {new URL(url).hostname}
    
  

); }

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 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. 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 ; if (!data?.title) return null;

return (

  {data.image && }
  
    {data.title}
    {data.description && {data.description}}
    
      {data.favicon && }
      {new URL(url).hostname}
    
  

); }

No backend needed — you call the API directly from React (CORS is handled). Free tier: 100 requests/day. 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… }

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.

0 views
Back to Blog

Related posts

Read more »