Guides
Build a link preview service
Create rich previews for URLs shared in your application
Build a link preview service
Create a service that generates rich previews for URLs shared in chat applications, social platforms, or content management systems.
What you'll build
A link preview service that returns:
- Title and description from meta tags
- A thumbnail screenshot of the page
- Favicon and site name
{
"url": "https://example.com/article",
"title": "Example Article",
"description": "This is an example article...",
"siteName": "Example Site",
"favicon": "https://example.com/favicon.ico",
"thumbnail": "https://storage.allscreenshots.com/preview-abc123.png"
}Architecture
┌──────────┐ ┌──────────────┐ ┌─────────────────┐
│ Client │ --> │ Your API │ --> │ AllScreenshots │
└──────────┘ │ (cache + │ │ API │
│ metadata) │ └─────────────────┘
└──────────────┘Implementation
Create the API endpoint
// api/preview/route.js
import { parse } from 'node-html-parser';
export async function GET(request) {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
if (!url) {
return Response.json({ error: 'URL required' }, { status: 400 });
}
// Check cache first
const cacheKey = `preview:${url}`;
const cached = await cache.get(cacheKey);
if (cached) {
return Response.json(cached);
}
// Generate preview
const preview = await generatePreview(url);
// Cache for 24 hours
await cache.set(cacheKey, preview, { ttl: 86400 });
return Response.json(preview);
}Fetch metadata
async function fetchMetadata(url) {
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; LinkPreviewBot/1.0)',
},
});
const html = await response.text();
const root = parse(html);
// Extract Open Graph tags
const og = (property) =>
root.querySelector(`meta[property="og:${property}"]`)?.getAttribute('content');
// Extract Twitter tags as fallback
const twitter = (name) =>
root.querySelector(`meta[name="twitter:${name}"]`)?.getAttribute('content');
// Extract standard meta tags
const meta = (name) =>
root.querySelector(`meta[name="${name}"]`)?.getAttribute('content');
return {
title: og('title') || twitter('title') || root.querySelector('title')?.text || '',
description: og('description') || twitter('description') || meta('description') || '',
siteName: og('site_name') || new URL(url).hostname,
image: og('image') || twitter('image') || null,
favicon: root.querySelector('link[rel="icon"]')?.getAttribute('href') ||
root.querySelector('link[rel="shortcut icon"]')?.getAttribute('href') ||
`${new URL(url).origin}/favicon.ico`,
};
} catch (error) {
console.error('Failed to fetch metadata:', error);
return {
title: new URL(url).hostname,
description: '',
siteName: new URL(url).hostname,
image: null,
favicon: null,
};
}
}Capture thumbnail
async function captureThumbnail(url) {
const response = await fetch('https://api.allscreenshots.com/v1/screenshots', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ALLSCREENSHOTS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
viewport: {
width: 1200,
height: 630, // OG image dimensions
},
format: 'png',
quality: 80,
waitUntil: 'networkidle',
blockAds: true,
blockCookieBanners: true,
responseType: 'json',
}),
});
if (!response.ok) {
throw new Error('Failed to capture screenshot');
}
const result = await response.json();
return result.url;
}Combine into preview
async function generatePreview(url) {
// Fetch metadata and thumbnail in parallel
const [metadata, thumbnail] = await Promise.all([
fetchMetadata(url),
captureThumbnail(url).catch(() => null),
]);
return {
url,
title: metadata.title,
description: metadata.description,
siteName: metadata.siteName,
favicon: metadata.favicon,
// Use OG image if available, fall back to screenshot
thumbnail: metadata.image || thumbnail,
};
}Frontend component
function LinkPreview({ url }) {
const { data: preview, isLoading, error } = useQuery(
['preview', url],
() => fetch(`/api/preview?url=${encodeURIComponent(url)}`).then(r => r.json()),
{ staleTime: 86400000 } // Cache for 24 hours
);
if (isLoading) {
return (
<div className="link-preview loading">
<div className="skeleton-image" />
<div className="skeleton-text" />
</div>
);
}
if (error || !preview) {
return (
<a href={url} className="link-preview fallback">
{url}
</a>
);
}
return (
<a href={url} className="link-preview" target="_blank" rel="noopener">
{preview.thumbnail && (
<img
src={preview.thumbnail}
alt={preview.title}
className="preview-image"
/>
)}
<div className="preview-content">
<div className="preview-site">
{preview.favicon && (
<img src={preview.favicon} alt="" className="favicon" />
)}
<span>{preview.siteName}</span>
</div>
<h3 className="preview-title">{preview.title}</h3>
{preview.description && (
<p className="preview-description">{preview.description}</p>
)}
</div>
</a>
);
}Styling
.link-preview {
display: flex;
flex-direction: column;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
text-decoration: none;
color: inherit;
max-width: 400px;
}
.link-preview:hover {
border-color: #ccc;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.preview-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.preview-content {
padding: 12px;
}
.preview-site {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.favicon {
width: 16px;
height: 16px;
}
.preview-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 4px;
line-height: 1.3;
}
.preview-description {
font-size: 14px;
color: #666;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}Optimizations
Queue processing
For high-volume applications, queue preview generation:
import { Queue } from 'bullmq';
const previewQueue = new Queue('link-previews');
// API endpoint adds to queue
app.get('/api/preview', async (req, res) => {
const { url } = req.query;
// Check cache
const cached = await cache.get(`preview:${url}`);
if (cached) {
return res.json(cached);
}
// Check if already processing
const existing = await previewQueue.getJob(url);
if (existing) {
// Wait for existing job
const result = await existing.waitUntilFinished(previewQueue.queueEvents);
return res.json(result);
}
// Add to queue
const job = await previewQueue.add('generate', { url }, { jobId: url });
const result = await job.waitUntilFinished(previewQueue.queueEvents);
res.json(result);
});
// Worker processes queue
const worker = new Worker('link-previews', async (job) => {
const preview = await generatePreview(job.data.url);
await cache.set(`preview:${job.data.url}`, preview, { ttl: 86400 });
return preview;
});Bulk preview generation
When users paste multiple links:
async function generateBulkPreviews(urls) {
// First, check cache for all URLs
const cached = await Promise.all(
urls.map(url => cache.get(`preview:${url}`))
);
const uncachedUrls = urls.filter((_, i) => !cached[i]);
if (uncachedUrls.length === 0) {
return urls.map((url, i) => cached[i]);
}
// Generate screenshots for uncached URLs
const bulkResponse = await fetch('https://api.allscreenshots.com/v1/screenshots/bulk', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ALLSCREENSHOTS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
urls: uncachedUrls.map(url => ({ url })),
defaults: {
viewport: { width: 1200, height: 630 },
format: 'png',
blockAds: true,
blockCookieBanners: true,
},
}),
});
// Fetch metadata in parallel
const metadataResults = await Promise.all(
uncachedUrls.map(url => fetchMetadata(url))
);
// Wait for bulk job to complete
const { bulkJobId } = await bulkResponse.json();
const screenshotResults = await waitForBulkJob(bulkJobId);
// Combine and cache results
const newPreviews = uncachedUrls.map((url, i) => ({
url,
...metadataResults[i],
thumbnail: screenshotResults.jobs[i]?.result?.url || null,
}));
await Promise.all(
newPreviews.map(preview =>
cache.set(`preview:${preview.url}`, preview, { ttl: 86400 })
)
);
// Merge cached and new results
let newIndex = 0;
return urls.map((url, i) => {
if (cached[i]) return cached[i];
return newPreviews[newIndex++];
});
}Best practices
Always cache previews aggressively. Most link content doesn't change frequently.
Rate limiting
Protect your endpoint from abuse:
import rateLimit from 'express-rate-limit';
const previewLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 requests per minute
message: { error: 'Too many requests' },
});
app.use('/api/preview', previewLimiter);URL validation
Validate and sanitize input URLs:
function validateUrl(url) {
try {
const parsed = new URL(url);
// Only allow HTTP(S)
if (!['http:', 'https:'].includes(parsed.protocol)) {
return null;
}
// Block private IPs
if (isPrivateIP(parsed.hostname)) {
return null;
}
return parsed.href;
} catch {
return null;
}
}Error handling
Gracefully handle failures:
async function generatePreview(url) {
const preview = {
url,
title: new URL(url).hostname,
description: null,
siteName: new URL(url).hostname,
favicon: null,
thumbnail: null,
error: null,
};
try {
const metadata = await fetchMetadata(url);
Object.assign(preview, metadata);
} catch (e) {
preview.error = 'metadata_failed';
}
try {
preview.thumbnail = await captureThumbnail(url);
} catch (e) {
preview.error = preview.error || 'thumbnail_failed';
}
return preview;
}