DEV Community

eatyou eatyou
eatyou eatyou

Posted on

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

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);
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 } }
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

Use it like:

function FeedItem({ url }) {
  const { data, loading } = useLinkPreview(url);
  // render...
}
Enter fullscreen mode Exit fullscreen mode

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)