Build a Link Preview Service in 5 Minutes with a Screenshot API
You know that thing Slack does when you paste a URL? The little card with a title, description, image, and favicon? I always assumed it was complicated to build.
It's not. It's about 40 lines of code.
In this tutorial, we'll build a link preview microservice from scratch — the kind you'd plug into a chat app, CMS, or social feed. It'll extract metadata, generate a thumbnail screenshot as a fallback, and return a nice JSON payload.
Here's what the final result looks like:
GET /preview?url=https://github.com
{
"title": "GitHub: Let's build from here",
"description": "GitHub is where over 100 million developers shape the future...",
"image": "https://github.githubassets.com/images/modules/site/social-cards/...",
"screenshot": "https://your-s3.com/previews/github-com.webp",
"favicon": "https://github.com/favicon.ico",
"domain": "github.com"
}
The Architecture
Here's how the pieces fit together:
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Your App │────▶│ Preview Service │────▶│ SnapAPI │
│ (Frontend) │◀────│ (Node/Express) │◀────│ (Screenshot │
└──────────────┘ │ │ │ + Extract) │
JSON │ ┌─────────────┐ │ └──────────────┘
│ │ LRU Cache │ │
│ │ (in-memory) │ │
│ └─────────────┘ │
└──────────────────┘
Flow:
1. App sends URL to your preview service
2. Service checks cache → hit? Return immediately
3. Cache miss → two parallel API calls:
a. Extract metadata (title, OG tags, favicon)
b. Capture screenshot thumbnail (WebP, 600x400)
4. Combine results, cache for 1 hour, return JSON
What You'll Need
- Node.js 18+ (or Python 3.8+, I'll show both)
- A free SnapAPI key — snapapi.pics, 100 requests/month, no credit card
- 5 minutes
Step 1: Set Up the Project (Node.js)
mkdir link-preview && cd link-preview
npm init -y
npm install express
That's it. No headless Chrome, no Puppeteer, no 400MB of Chromium binaries. Just Express.
Set your API key:
export SNAPAPI_KEY="sk_live_your_key_here"
Step 2: Build the Preview Service
Create server.js:
const express = require('express');
const app = express();
// Dead-simple LRU cache (no dependencies needed)
const cache = new Map();
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
const MAX_CACHE = 500;
function getCached(url) {
const entry = cache.get(url);
if (!entry) return null;
if (Date.now() - entry.time > CACHE_TTL) {
cache.delete(url);
return null;
}
return entry.data;
}
function setCache(url, data) {
if (cache.size >= MAX_CACHE) {
// Evict oldest entry
const oldest = cache.keys().next().value;
cache.delete(oldest);
}
cache.set(url, { data, time: Date.now() });
}
// The main endpoint
app.get('/preview', async (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'url parameter required' });
// Check cache first
const cached = getCached(url);
if (cached) return res.json({ ...cached, cached: true });
try {
// Fire both requests in parallel — this is the key optimization
const [metaResult, screenshotResult] = await Promise.allSettled([
// 1. Extract metadata (title, description, OG tags)
fetch('https://api.snapapi.pics/v1/extract', {
method: 'POST',
headers: {
'x-api-key': process.env.SNAPAPI_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url, type: 'metadata' }),
}).then(r => r.json()),
// 2. Capture a small screenshot as fallback thumbnail
fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: {
'x-api-key': process.env.SNAPAPI_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
format: 'webp',
width: 600,
height: 400,
blockCookieBanners: true,
blockAds: true,
}),
}).then(async r => {
// Convert to base64 data URL for simplicity
// In production, upload to S3 instead
const buf = Buffer.from(await r.arrayBuffer());
return `data:image/webp;base64,${buf.toString('base64')}`;
}),
]);
const meta = metaResult.status === 'fulfilled' ? metaResult.value.data : {};
const screenshot = screenshotResult.status === 'fulfilled' ? screenshotResult.value : null;
const domain = new URL(url).hostname;
const preview = {
title: meta.ogTitle || meta.title || domain,
description: meta.ogDescription || meta.description || '',
image: meta.ogImage || screenshot, // OG image preferred, screenshot as fallback
screenshot,
favicon: meta.favicon || `https://${domain}/favicon.ico`,
domain,
url,
};
setCache(url, preview);
res.json(preview);
} catch (err) {
res.status(500).json({ error: 'Failed to generate preview', detail: err.message });
}
});
app.listen(3000, () => console.log('Preview service running on :3000'));
Run it:
node server.js
Test it:
curl "http://localhost:3000/preview?url=https://github.com" | jq
That's it. You have a working link preview service. Under 80 lines of code.
Step 3: The Python Version
If Python's your thing, here's the equivalent with Flask:
import os
import time
import base64
from urllib.parse import urlparse
from concurrent.futures import ThreadPoolExecutor
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
executor = ThreadPoolExecutor(max_workers=4)
# Simple cache
cache = {}
CACHE_TTL = 3600 # 1 hour
API_KEY = os.environ['SNAPAPI_KEY']
HEADERS = {'x-api-key': API_KEY, 'Content-Type': 'application/json'}
def fetch_metadata(url):
r = requests.post('https://api.snapapi.pics/v1/extract',
headers=HEADERS,
json={'url': url, 'type': 'metadata'})
return r.json().get('data', {})
def fetch_screenshot(url):
r = requests.post('https://api.snapapi.pics/v1/screenshot',
headers=HEADERS,
json={
'url': url,
'format': 'webp',
'width': 600,
'height': 400,
'blockCookieBanners': True,
'blockAds': True,
})
return f"data:image/webp;base64,{base64.b64encode(r.content).decode()}"
@app.route('/preview')
def preview():
url = request.args.get('url')
if not url:
return jsonify(error='url parameter required'), 400
# Check cache
if url in cache and time.time() - cache[url]['time'] < CACHE_TTL:
return jsonify({**cache[url]['data'], 'cached': True})
# Parallel requests
meta_future = executor.submit(fetch_metadata, url)
screenshot_future = executor.submit(fetch_screenshot, url)
meta = meta_future.result()
screenshot = screenshot_future.result()
domain = urlparse(url).hostname
preview_data = {
'title': meta.get('ogTitle') or meta.get('title') or domain,
'description': meta.get('ogDescription') or meta.get('description', ''),
'image': meta.get('ogImage') or screenshot,
'screenshot': screenshot,
'favicon': meta.get('favicon') or f'https://{domain}/favicon.ico',
'domain': domain,
'url': url,
}
cache[url] = {'data': preview_data, 'time': time.time()}
return jsonify(preview_data)
if __name__ == '__main__':
app.run(port=3000)
pip install flask requests
SNAPAPI_KEY=sk_live_xxx python server.py
Step 4: Making It Production-Ready
The above works, but here are the upgrades for production:
Add Rate Limiting
// At the top of server.js
const rateLimit = new Map();
app.use((req, res, next) => {
const ip = req.ip;
const now = Date.now();
const window = rateLimit.get(ip) || [];
const recent = window.filter(t => now - t < 60000); // 1 minute window
if (recent.length >= 30) {
return res.status(429).json({ error: 'Rate limited. 30 requests/minute.' });
}
recent.push(now);
rateLimit.set(ip, recent);
next();
});
Upload Screenshots to S3 Instead of Base64
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const crypto = require('crypto');
const s3 = new S3Client({ region: 'us-east-1' });
async function uploadScreenshot(buffer, url) {
const hash = crypto.createHash('md5').update(url).digest('hex');
const key = `previews/${hash}.webp`;
await s3.send(new PutObjectCommand({
Bucket: 'your-bucket',
Key: key,
Body: buffer,
ContentType: 'image/webp',
CacheControl: 'public, max-age=86400',
}));
return `https://your-bucket.s3.amazonaws.com/${key}`;
}
Use Redis Instead of In-Memory Cache
const Redis = require('ioredis');
const redis = new Redis();
async function getCached(url) {
const data = await redis.get(`preview:${url}`);
return data ? JSON.parse(data) : null;
}
async function setCache(url, data) {
await redis.setex(`preview:${url}`, 3600, JSON.stringify(data));
}
Step 5: Bonus — Embed Component
Here's a React component that uses your preview service:
function LinkPreview({ url }) {
const [preview, setPreview] = useState(null);
useEffect(() => {
fetch(`/preview?url=${encodeURIComponent(url)}`)
.then(r => r.json())
.then(setPreview);
}, [url]);
if (!preview) return <div className="link-preview skeleton" />;
return (
<a href={url} className="link-preview" target="_blank" rel="noopener">
<img src={preview.image} alt="" className="preview-image" />
<div className="preview-body">
<div className="preview-domain">
<img src={preview.favicon} alt="" width="16" height="16" />
{preview.domain}
</div>
<h4>{preview.title}</h4>
<p>{preview.description}</p>
</div>
</a>
);
}
Performance Notes
Some numbers from my testing:
| Scenario | Response Time |
|---|---|
| Cache hit | ~2ms |
| Metadata only (OG tags exist) | ~800ms |
| Metadata + screenshot (parallel) | ~2.5s |
| Full page screenshot (complex site) | ~4s |
The parallel Promise.allSettled trick is crucial — it cuts total latency nearly in half vs. sequential calls.
Cost Breakdown
Using SnapAPI's free tier (100 requests/month), you get:
- 100 link previews/month at $0
- $9/month gets you 5,000 — that's $0.0018 per preview
- Compare that to running your own Playwright instance: ~$15/month for a VPS + your time maintaining it
For most side projects, the free tier is enough. For production apps handling thousands of previews, the $9 plan is a no-brainer vs. self-hosting.
What We Built
┌─────────────────────────────────────────────────┐
│ Link Preview Service │
├─────────────────────────────────────────────────┤
│ │
│ ✅ Extracts OG metadata (title, desc, image) │
│ ✅ Falls back to screenshot when no OG image │
│ ✅ In-memory LRU cache (1 hour TTL) │
│ ✅ Parallel API calls for speed │
│ ✅ Blocks cookie banners and ads │
│ ✅ ~80 lines of code, zero infrastructure │
│ │
│ Total build time: ~5 minutes │
│ Dependencies: express (or flask) │
│ Running cost: $0/month (free tier) │
│ │
└─────────────────────────────────────────────────┘
The full source code for both versions is in this GitHub Gist (TODO: link).
If you're building something with link previews or screenshots, I'd genuinely love to hear about it. Drop a comment — I read all of them.
SnapAPI — the API I used in this tutorial. Free tier, no credit card, takes 30 seconds to get a key.
Top comments (0)