DEV Community

eatyou eatyou
eatyou eatyou

Posted on

How to Add Link Previews to Your React App

Link previews — those little cards that show a title, description, and image when you paste a URL — are a great UX touch for chat apps, social feeds, or any interface where users share links.

In this tutorial, we will build a reusable React component that fetches and displays link previews.

The Problem

Browsers block cross-origin requests for security reasons. You cannot fetch https://github.com from your React app and read its <meta> tags directly — CORS will stop you.

The solution: a server-side proxy that fetches the URL, parses the HTML, and returns structured metadata.

Building the Hook

Let us start with a useLinkPreview hook:

import { useState, useEffect } from 'react';

function useLinkPreview(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!url) return;

    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(`/api/preview?url=${encodeURIComponent(url)}`, {
      signal: controller.signal,
    })
      .then((res) => {
        if (!res.ok) throw new Error('Failed to fetch preview');
        return res.json();
      })
      .then((json) => {
        setData(json);
        setLoading(false);
      })
      .catch((err) => {
        if (err.name !== 'AbortError') {
          setError(err.message);
          setLoading(false);
        }
      });

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}
Enter fullscreen mode Exit fullscreen mode

The AbortController cancels in-flight requests when the URL changes or the component unmounts — important to avoid stale state updates.

The Preview Component

function LinkPreview({ url }) {
  const { data, loading, error } = useLinkPreview(url);

  if (!url) return null;

  if (loading) {
    return (
      <div className="link-preview link-preview--loading">
        <div className="skeleton skeleton--image" />
        <div className="skeleton-lines">
          <div className="skeleton skeleton--title" />
          <div className="skeleton skeleton--desc" />
        </div>
      </div>
    );
  }

  if (error || !data) return null;

  return (
    <a
      href={url}
      target="_blank"
      rel="noopener noreferrer"
      className="link-preview"
    >
      {data.image && (
        <img
          src={data.image}
          alt={data.title || ''}
          className="link-preview__image"
          onError={(e) => { e.target.style.display = 'none'; }}
        />
      )}
      <div className="link-preview__body">
        <p className="link-preview__title">{data.title}</p>
        {data.description && (
          <p className="link-preview__desc">{data.description}</p>
        )}
        <span className="link-preview__domain">{new URL(url).hostname}</span>
      </div>
    </a>
  );
}
Enter fullscreen mode Exit fullscreen mode

CSS Styling

.link-preview {
  display: flex;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  overflow: hidden;
  text-decoration: none;
  color: inherit;
  max-width: 480px;
  transition: box-shadow 0.2s;
}

.link-preview:hover {
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}

.link-preview__image {
  width: 120px;
  min-height: 80px;
  object-fit: cover;
  flex-shrink: 0;
}

.link-preview__body {
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.link-preview__title {
  font-weight: 600;
  font-size: 14px;
  margin: 0;
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.link-preview__desc {
  font-size: 12px;
  color: #64748b;
  margin: 0;
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.link-preview__domain {
  font-size: 11px;
  color: #94a3b8;
  margin-top: auto;
}

/* Skeleton loading */
.skeleton {
  background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

.skeleton--image { width: 120px; min-height: 80px; }
.skeleton--title { height: 14px; width: 80%; margin-bottom: 8px; }
.skeleton--desc { height: 12px; width: 60%; }
Enter fullscreen mode Exit fullscreen mode

Setting Up the API Route

If you are using Next.js, create pages/api/preview.js:

export default async function handler(req, res) {
  const { url } = req.query;
  if (!url) return res.status(400).json({ error: 'url is required' });

  try {
    const response = await fetch(url, {
      headers: { 'User-Agent': 'Mozilla/5.0 (compatible; LinkPreviewBot/1.0)' },
      redirect: 'follow',
    });
    const html = await response.text();

    const getMeta = (name) => {
      const match =
        html.match(new RegExp(`<meta[^>]+(?:property|name)=["']${name}["'][^>]+content=["']([^"']+)["']`, 'i')) ||
        html.match(new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${name}["']`, 'i'));
      return match ? match[1] : null;
    };

    const title =
      getMeta('og:title') ||
      (html.match(/<title>([^<]+)<\/title>/i) || [])[1] ||
      '';

    res.json({
      title: title.trim(),
      description: getMeta('og:description') || getMeta('description') || '',
      image: getMeta('og:image') || '',
      favicon: `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}`,
    });
  } catch (err) {
    res.status(500).json({ error: 'Failed to fetch URL' });
  }
}
Enter fullscreen mode Exit fullscreen mode

Using It in Your App

function ChatMessage({ message }) {
  const urlMatch = message.text.match(/https?:\/\/[^\s]+/);
  const url = urlMatch ? urlMatch[0] : null;

  return (
    <div className="message">
      <p>{message.text}</p>
      {url && <LinkPreview url={url} />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Performance Tips

1. Debounce with useDeferredValue. If users type URLs, avoid fetching on every keystroke:

import { useDeferredValue } from 'react';

const deferredUrl = useDeferredValue(inputUrl);
const { data } = useLinkPreview(deferredUrl);
Enter fullscreen mode Exit fullscreen mode

2. Cache with SWR. Avoid re-fetching the same URL:

import useSWR from 'swr';

const fetcher = (url) => fetch(url).then((r) => r.json());

function useLinkPreview(url) {
  const apiUrl = url ? `/api/preview?url=${encodeURIComponent(url)}` : null;
  return useSWR(apiUrl, fetcher, { revalidateOnFocus: false });
}
Enter fullscreen mode Exit fullscreen mode

3. Validate URLs before fetching:

function isValidUrl(str) {
  try {
    const u = new URL(str);
    return u.protocol === 'http:' || u.protocol === 'https:';
  } catch {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Skip Building the Backend

Writing the metadata parser yourself is fine for personal projects, but there are edge cases: relative image URLs, charset encoding, redirect chains, Twitter-specific tags, and more.

If you want to skip the server work, LinkPeek is a free API that handles all of this. The free tier gives you 100 requests/day — enough for most side projects:

const res = await fetch(
  `https://linkpeek-api.linkpeek.workers.dev/v1/preview?url=${encodeURIComponent(url)}&key=YOUR_KEY`
);
const data = await res.json();
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

A link preview component is a small feature with a big UX impact. The key pieces:

  1. A server-side proxy to bypass CORS
  2. Graceful loading states with skeleton UI
  3. Error handling that fails silently (no ugly broken cards)
  4. Caching to avoid redundant fetches

Now go build something your users will actually enjoy using.

Top comments (0)