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

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:

  1. Build a preview service from scratch.
  2. 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.

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.

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

ApproachProsCons
DIY Express proxyFull control, no external costRequires maintenance, misses SPA meta tags, CORS setup
Link Preview APIZero backend, handles edge casesRelies 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.

0 views
Back to Blog

Related posts

Read more »