Screenshot API for Link Preview Generation: Complete Implementation
Create beautiful link previews with automatic screenshot capture and metadata extraction.
Link previews make shared content more engaging. This guide covers building Twitter/Slack-style previews using screenshots and metadata.
How Link Previews Work
User shares: https://example.com/article
→ Fetch page metadata (title, description, OG image)
→ If no OG image: capture screenshot
→ Display rich preview card
Basic Implementation
async function generateLinkPreview(url) {
// 1. Fetch page metadata
const metadata = await fetchMetadata(url);
// 2. Get or generate preview image
const image = metadata.ogImage || await captureScreenshot(url);
// 3. Return preview data
return {
url,
title: metadata.title,
description: metadata.description,
image,
favicon: metadata.favicon,
siteName: metadata.siteName,
};
}
Metadata Extraction
import cheerio from 'cheerio';
async function fetchMetadata(url) {
const response = await fetch(url, {
headers: { 'User-Agent': 'LinkPreviewBot/1.0' },
});
const html = await response.text();
const $ = cheerio.load(html);
return {
title: $('meta[property="og:title"]').attr('content')
|| $('title').text(),
description: $('meta[property="og:description"]').attr('content')
|| $('meta[name="description"]').attr('content'),
ogImage: $('meta[property="og:image"]').attr('content'),
siteName: $('meta[property="og:site_name"]').attr('content'),
favicon: $('link[rel="icon"]').attr('href')
|| $('link[rel="shortcut icon"]').attr('href'),
};
}
Screenshot Fallback
async function captureScreenshot(url) {
const response = await fetch('https://api.screenshotly.app/screenshot', {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
viewport: { width: 1200, height: 630 }, // OG image dimensions
format: 'png',
}),
});
// Upload to storage and return URL
const imageBuffer = await response.arrayBuffer();
return uploadToStorage(imageBuffer);
}
Complete Link Preview Service
class LinkPreviewService {
constructor(apiKey, storage) {
this.apiKey = apiKey;
this.storage = storage;
this.cache = new Map();
}
async getPreview(url) {
// Check cache
const cached = this.cache.get(url);
if (cached && Date.now() - cached.timestamp < 86400000) {
return cached.data;
}
// Generate preview
const preview = await this.generatePreview(url);
// Cache result
this.cache.set(url, {
data: preview,
timestamp: Date.now(),
});
return preview;
}
async generatePreview(url) {
const metadata = await this.fetchMetadata(url);
// Use OG image if available and valid
let image = null;
if (metadata.ogImage) {
const valid = await this.validateImage(metadata.ogImage);
if (valid) {
image = metadata.ogImage;
}
}
// Capture screenshot as fallback
if (!image) {
image = await this.captureAndStore(url);
}
return {
url,
title: metadata.title || this.extractDomain(url),
description: metadata.description || '',
image,
favicon: this.resolveFavicon(url, metadata.favicon),
siteName: metadata.siteName || this.extractDomain(url),
};
}
async validateImage(imageUrl) {
try {
const response = await fetch(imageUrl, { method: 'HEAD' });
const contentType = response.headers.get('content-type');
return contentType?.startsWith('image/');
} catch {
return false;
}
}
extractDomain(url) {
return new URL(url).hostname.replace('www.', '');
}
resolveFavicon(baseUrl, faviconPath) {
if (!faviconPath) {
return `https://www.google.com/s2/favicons?domain=${baseUrl}`;
}
if (faviconPath.startsWith('http')) {
return faviconPath;
}
const base = new URL(baseUrl);
return `${base.origin}${faviconPath}`;
}
}
React Preview Component
function LinkPreview({ url }) {
const [preview, setPreview] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
const data = await fetch(`/api/preview?url=${encodeURIComponent(url)}`);
setPreview(await data.json());
setLoading(false);
}
load();
}, [url]);
if (loading) {
return <div className="preview-skeleton" />;
}
return (
<a href={url} className="link-preview" target="_blank" rel="noopener">
<div className="preview-image">
<img src={preview.image} alt="" loading="lazy" />
</div>
<div className="preview-content">
<div className="preview-site">
<img src={preview.favicon} alt="" className="favicon" />
<span>{preview.siteName}</span>
</div>
<h3 className="preview-title">{preview.title}</h3>
<p className="preview-description">{preview.description}</p>
</div>
</a>
);
}
CSS Styling
.link-preview {
display: block;
border: 1px solid #e1e4e8;
border-radius: 12px;
overflow: hidden;
text-decoration: none;
color: inherit;
transition: box-shadow 0.2s;
}
.link-preview:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.preview-image img {
width: 100%;
height: 200px;
object-fit: cover;
}
.preview-content {
padding: 16px;
}
.preview-site {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #666;
}
.favicon {
width: 16px;
height: 16px;
}
.preview-title {
margin: 8px 0 4px;
font-size: 16px;
font-weight: 600;
}
.preview-description {
font-size: 14px;
color: #666;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
API Endpoint
// pages/api/preview.js
export default async function handler(req, res) {
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL required' });
}
try {
new URL(url);
} catch {
return res.status(400).json({ error: 'Invalid URL' });
}
const preview = await linkPreviewService.getPreview(url);
res.setHeader('Cache-Control', 'public, max-age=86400');
res.json(preview);
}
Best Practices for Production Link Previews
1. Cache Aggressively
Link previews rarely change — a page's title, description, and OG image don't update more than once a day at most. Cache previews for at least 24 hours to avoid unnecessary API calls and metadata fetches.
For high-traffic applications, use a multi-layer cache strategy:
class CachedPreviewService {
constructor(redis, screenshotService) {
this.redis = redis;
this.memCache = new Map(); // L1: in-memory
this.service = screenshotService;
}
async getPreview(url) {
// L1: Check memory cache (fastest)
const memCached = this.memCache.get(url);
if (memCached && Date.now() - memCached.ts < 3600000) {
return memCached.data;
}
// L2: Check Redis (shared across instances)
const redisCached = await this.redis.get(`preview:${url}`);
if (redisCached) {
const data = JSON.parse(redisCached);
this.memCache.set(url, { data, ts: Date.now() });
return data;
}
// L3: Generate fresh preview
const preview = await this.service.generatePreview(url);
await this.redis.setex(`preview:${url}`, 86400, JSON.stringify(preview));
this.memCache.set(url, { data: preview, ts: Date.now() });
return preview;
}
}
2. Validate OG Images Before Using Them
Not all OG images are usable. Some are broken links, some are tiny tracking pixels, and some are placeholder images. Always validate before displaying:
async function validateOgImage(imageUrl) {
try {
const response = await fetch(imageUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return false;
const contentType = response.headers.get('content-type');
if (!contentType?.startsWith('image/')) return false;
// Reject tiny images (likely tracking pixels)
const contentLength = parseInt(response.headers.get('content-length') || '0');
if (contentLength > 0 && contentLength < 1000) return false;
return true;
} catch {
return false;
}
}
3. Set Strict Timeouts
Some websites take 10+ seconds to respond during metadata extraction. Never let a slow third-party site block your application. Set aggressive timeouts at every layer:
- Metadata fetch: 5 seconds max
- Screenshot capture: 15 seconds max (handled by the API)
- OG image validation: 3 seconds max
- Total preview generation: 20 seconds max
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: { 'User-Agent': 'LinkPreviewBot/1.0' },
});
// ... process response
} finally {
clearTimeout(timeout);
}
4. Handle Errors Gracefully
When preview generation fails (site down, blocked, timeout), show a minimal fallback instead of nothing:
function fallbackPreview(url) {
const domain = new URL(url).hostname.replace('www.', '');
return {
url,
title: domain,
description: url,
image: null, // Show domain icon or placeholder
favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=32`,
siteName: domain,
};
}
5. Lazy Load Preview Cards
Don't fetch previews until they're close to being visible. This is especially important for chat applications where users might have dozens of links in a conversation:
function LinkPreviewLazy({ url }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setVisible(true); },
{ rootMargin: '200px' } // Start loading 200px before visible
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return (
<div ref={ref}>
{visible ? <LinkPreview url={url} /> : <PreviewSkeleton />}
</div>
);
}
Security Considerations
Link preview generation introduces security risks that must be addressed:
SSRF (Server-Side Request Forgery): Your preview endpoint fetches arbitrary URLs. An attacker could use it to scan your internal network. Always validate URLs before processing:
function isAllowedUrl(url) {
try {
const parsed = new URL(url);
// Only allow HTTP/HTTPS
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
// Block private IP ranges
const hostname = parsed.hostname;
if (hostname === 'localhost' || hostname === '127.0.0.1') return false;
if (hostname.startsWith('10.') || hostname.startsWith('192.168.')) return false;
if (hostname.match(/^172\.(1[6-9]|2\d|3[01])\./)) return false;
return true;
} catch {
return false;
}
}
Rate Limiting: Without rate limits, an attacker can use your preview endpoint to launch DDoS attacks against other sites via your server. Limit preview requests to 10–20 per minute per user.
Content Filtering: Some URLs may return inappropriate or malicious content. Consider scanning preview images before displaying them, and always sanitize metadata text to prevent XSS attacks.
Performance Optimization
For high-traffic applications generating thousands of previews per day, consider these optimizations:
-
Queue preview generation — Don't generate previews synchronously in the request path. Accept the URL, return immediately, and process in a background queue. Push results via WebSocket or Server-Sent Events.
-
Batch similar domains — If you're generating previews for 50 URLs from the same domain, batch the metadata fetches to avoid overwhelming the target server.
-
Use WebP format — When capturing screenshots as fallback images, use WebP format for 30–50% smaller files without visible quality loss. This significantly improves loading times for preview cards.
-
Serve from CDN — Store preview images on a CDN (Cloudflare R2, CloudFront) so they load instantly from edge locations worldwide.
FAQ
Why not just use the OG image? Many sites don't have OG images, or their OG images are low-quality logos rather than page previews. A screenshot fallback ensures every link gets a visual preview, even when the site doesn't provide one.
How do I handle sites that block bots? Enable stealth mode in the screenshot API call. This bypasses most bot detection by mimicking real browser fingerprints. For particularly aggressive anti-bot systems, adding a short delay (2–3 seconds) also helps.
What viewport size should I use for previews? The standard is 1200×630 pixels, which is the recommended OG image size. This works well for Twitter cards, Slack unfurls, Discord embeds, and most messaging applications.
Should I generate previews on the client or server? Always on the server. Client-side fetching of third-party URLs fails due to CORS restrictions, and it exposes your API keys. Use a server endpoint that handles metadata extraction and caching.
How do I handle dynamic/SPA sites? Single-page applications often need JavaScript to render content. The screenshot API handles this automatically since it uses a real browser engine. For metadata extraction, consider using a headless browser instead of simple HTTP fetches.
Ready to build rich link previews?
Get your free API key → — 100 free screenshots to get started.
See also:
About the Author

Asad Ali
Full-Stack Developer and Founder of ZTabs with 8+ years of experience building scalable web applications and APIs. Specializes in performance optimization, SaaS development, and modern web technologies.
Credentials: Founder & CEO at ZTabs, Full-Stack Developer, Expert in Next.js, React, Node.js, and API optimization
Ready to capture your first screenshot?
Get started with 100 free screenshots. No credit card required.