Building Link Previews for Your App
What Are Link Previews?
Link previews — also called URL unfurling or rich link cards — are the visual summaries that appear when a URL is shared in chat applications, social media, or content management systems. Slack, Discord, Twitter, Facebook, iMessage, and WhatsApp all display link previews automatically.
Building this functionality into your own application dramatically improves user experience. Instead of showing raw URLs, your app displays the page title, description, preview image, and favicon — giving users immediate context about where a link leads.
The Architecture of Link Preview Systems
Before writing code, let's understand the components of a link preview system:
Server-Side vs. Client-Side
Server-side unfurling (recommended):
- Your backend fetches the metadata and returns it to the frontend
- Avoids CORS issues
- Enables caching at the server level
- Protects against malicious URLs before they reach the client
Client-side unfurling (simpler, with tradeoffs):
- JavaScript in the browser calls a metadata API directly
- Simpler architecture but requires CORS support from the API
- No server-side caching or security filtering
LinkMeta supports both approaches — it has full CORS support for client-side calls and works perfectly as a server-side dependency.
The Unfurling Pipeline
A typical link preview pipeline:
- User pastes a URL — Detect URL patterns in user input
- Debounce — Wait 300-500ms after typing stops to avoid excessive requests
- Extract metadata — Call the LinkMeta API with the URL
- Cache result — Store the metadata to avoid redundant API calls
- Render preview — Display the rich card in your UI
- Handle errors — Show a fallback for unreachable URLs
Step 1: Detecting URLs in User Input
First, detect when a user types or pastes a URL:
const URL_REGEX = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/g;
function detectUrls(text) {
return text.match(URL_REGEX) || [];
}
const input = document.querySelector('#message-input');
let debounceTimer;
input.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const urls = detectUrls(e.target.value);
if (urls.length > 0) {
unfurlUrl(urls[0]);
}
}, 400);
});
Step 2: Fetching Metadata
Use the LinkMeta API to extract metadata from the detected URL:
Client-Side (Browser)
async function unfurlUrl(url) {
const apiUrl = `https://linkmeta.dev/api/v1/extract?url=${encodeURIComponent(url)}&fields=title,description,image,favicon`;
const response = await fetch(apiUrl);
if (!response.ok) return null;
const json = await response.json();
return json.data;
}
Server-Side (Node.js)
async function unfurlUrl(url) {
const response = await fetch(
`https://linkmeta.dev/api/v1/extract?url=${encodeURIComponent(url)}&fields=title,description,image,favicon`
);
if (!response.ok) return null;
const json = await response.json();
return json.data;
}
app.post('/api/unfurl', async (req, res) => {
const { url } = req.body;
if (!url || !url.startsWith('https://')) {
return res.status(400).json({ error: 'Invalid URL' });
}
const metadata = await unfurlUrl(url);
res.json(metadata || { error: 'Could not fetch metadata' });
});
Python (Flask)
import requests
from flask import Flask, request, jsonify
app = Flask(__name__)
def unfurl_url(url):
response = requests.get('https://linkmeta.dev/api/v1/extract', params={
'url': url,
'fields': 'title,description,image,favicon'
})
if response.status_code != 200:
return None
return response.json().get('data')
@app.route('/api/unfurl', methods=['POST'])
def unfurl():
url = request.json.get('url')
if not url:
return jsonify({'error': 'URL required'}), 400
metadata = unfurl_url(url)
return jsonify(metadata or {'error': 'Could not fetch metadata'})
Step 3: Caching Metadata
Fetching metadata on every request is wasteful. Implement caching:
In-Memory Cache (Node.js)
const cache = new Map();
const CACHE_TTL = 3600 * 1000;
async function unfurlWithCache(url) {
const cached = cache.get(url);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
const data = await unfurlUrl(url);
if (data) {
cache.set(url, { data, timestamp: Date.now() });
}
return data;
}
Database Cache (For Production)
For production applications, store unfurled metadata in your database:
CREATE TABLE link_previews (
url TEXT PRIMARY KEY,
title TEXT,
description TEXT,
image TEXT,
favicon TEXT,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
async function unfurlWithDbCache(url, db) {
const cached = await db.get(
'SELECT * FROM link_previews WHERE url = ? AND fetched_at > datetime("now", "-1 hour")',
[url]
);
if (cached) return cached;
const metadata = await unfurlUrl(url);
if (metadata) {
await db.run(
'INSERT OR REPLACE INTO link_previews (url, title, description, image, favicon) VALUES (?, ?, ?, ?, ?)',
[url, metadata.title, metadata.description, metadata.image, metadata.favicon]
);
}
return metadata;
}
Step 4: Building the Preview UI
HTML + CSS Link Preview Card
<div class="link-preview">
<a href="https://example.com" target="_blank" rel="noopener">
<div class="link-preview-image">
<img src="https://example.com/og-image.png" alt="Page title" loading="lazy">
</div>
<div class="link-preview-content">
<div class="link-preview-domain">
<img src="https://example.com/favicon.ico" width="16" height="16" alt="">
example.com
</div>
<h3 class="link-preview-title">Page Title</h3>
<p class="link-preview-description">A short description of the page content.</p>
</div>
</a>
</div>
.link-preview {
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
max-width: 500px;
transition: box-shadow 0.2s ease;
}
.link-preview:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.link-preview a {
text-decoration: none;
color: inherit;
display: block;
}
.link-preview-image img {
width: 100%;
height: 200px;
object-fit: cover;
}
.link-preview-content {
padding: 12px 16px;
}
.link-preview-domain {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
.link-preview-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 4px;
line-height: 1.3;
}
.link-preview-description {
font-size: 14px;
color: var(--text-muted);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
React Component (Complete)
import { useState, useEffect, useCallback } from 'react';
function LinkPreview({ url, onDismiss }) {
const [meta, setMeta] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const fetchMetadata = useCallback(async () => {
try {
const res = await fetch(
`https://linkmeta.dev/api/v1/extract?url=${encodeURIComponent(url)}&fields=title,description,image,favicon`
);
if (!res.ok) throw new Error('Fetch failed');
const json = await res.json();
setMeta(json.data);
} catch {
setError(true);
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchMetadata();
}, [fetchMetadata]);
if (loading) {
return (
<div className="link-preview link-preview--loading">
<div className="link-preview-skeleton" />
</div>
);
}
if (error || !meta) return null;
const domain = new URL(url).hostname;
return (
<div className="link-preview">
{onDismiss && (
<button className="link-preview-dismiss" onClick={onDismiss} aria-label="Dismiss preview">
×
</button>
)}
<a href={url} target="_blank" rel="noopener noreferrer">
{meta.image && (
<div className="link-preview-image">
<img src={meta.image} alt={meta.title || ''} loading="lazy" />
</div>
)}
<div className="link-preview-content">
<div className="link-preview-domain">
{meta.favicon && <img src={meta.favicon} width="16" height="16" alt="" />}
{domain}
</div>
{meta.title && <h3 className="link-preview-title">{meta.title}</h3>}
{meta.description && <p className="link-preview-description">{meta.description}</p>}
</div>
</a>
</div>
);
}
export default LinkPreview;
Step 5: Handling Edge Cases
Loading States
Always show a skeleton loader while fetching metadata. Users should know something is happening:
.link-preview-skeleton {
height: 100px;
background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-panel) 50%, var(--bg-card) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 12px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Image Fallbacks
Not every URL has a preview image. Handle missing images gracefully:
{meta.image ? (
<img src={meta.image} alt={meta.title} onError={(e) => e.target.style.display = 'none'} />
) : (
<div className="link-preview-no-image">
<span className="link-preview-icon">{domain[0].toUpperCase()}</span>
</div>
)}
URL Validation
Before unfurling, validate that the URL is safe:
function isValidUrl(url) {
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol);
} catch {
return false;
}
}
Dismiss Functionality
Let users dismiss previews they don't want:
const [dismissed, setDismissed] = useState(new Set());
function handleDismiss(url) {
setDismissed(prev => new Set([...prev, url]));
}
Performance Considerations
- Debounce URL detection — Don't fire a request on every keystroke. Wait 300-500ms after the user stops typing
- Cache aggressively — The same URL shared by different users should return cached metadata
- Lazy load images — Use
loading="lazy"on preview images - Limit concurrent requests — If multiple URLs are detected, unfurl them sequentially or with a concurrency limit
- Set timeouts — Use LinkMeta's
timeoutparameter for slow-responding targets
Production Checklist
Before shipping link previews to production:
- URLs are validated before unfurling (protocol check, format check)
- Debouncing prevents excessive API calls
- Caching is implemented (in-memory or database)
- Loading skeletons are shown during fetch
- Image load errors are handled gracefully
- Users can dismiss unwanted previews
- Preview cards are accessible (proper alt text, keyboard navigable)
- Mobile responsive (cards resize properly on small screens)
Related SoftVoyagers Tools
Building a complete link sharing experience? These tools complement LinkMeta:
- OGForge — Generate Open Graph images for your own pages
- LinkShrink — Shorten URLs before sharing
- QRMint — Generate QR codes from URLs
- PageShot — Capture full-page screenshots of URLs
- PDFSpark — Convert web pages to PDF
All tools are part of the SoftVoyagers ecosystem — free, no signup required, no API keys.
Start building link previews now — try the interactive playground or read the API documentation.