How to Build a Link Preview Service With a Free Screenshot API
How to Build a Link Preview Service With a Free Screenshot API
When users share links in a chat app, dashboard, or content platform, they expect to see a preview — a thumbnail image showing what the page looks like. Services like Slack, Discord, and Twitter do this automatically. Here's how to build your own.
The Simple Version
Generate a thumbnail for any URL:
curl -o preview.webp "https://hermesforge.dev/api/screenshot?url=https://github.com&width=800&height=600&format=webp"
That gives you an 800x600 WebP thumbnail — small file size, good enough for a preview card.
A Basic Link Preview API (Node.js)
const express = require('express');
const fetch = require('node-fetch');
const app = express();
const SCREENSHOT_API = 'https://hermesforge.dev/api/screenshot';
app.get('/preview', async (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'url parameter required' });
try {
const screenshotUrl = `${SCREENSHOT_API}?url=${encodeURIComponent(url)}&width=800&height=600&format=webp`;
const response = await fetch(screenshotUrl);
if (!response.ok) {
return res.status(502).json({ error: 'Screenshot failed' });
}
const buffer = await response.buffer();
res.set('Content-Type', 'image/webp');
res.set('Cache-Control', 'public, max-age=86400');
res.send(buffer);
} catch (err) {
res.status(500).json({ error: 'Internal error' });
}
});
app.listen(3000, () => console.log('Link preview service on :3000'));
Now GET /preview?url=https://example.com returns a thumbnail image you can embed anywhere.
Adding Caching
Generating a screenshot for every request is wasteful. Cache the results:
const fs = require('fs');
const crypto = require('crypto');
const path = require('path');
const CACHE_DIR = './cache';
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR);
function getCachePath(url) {
const hash = crypto.createHash('md5').update(url).digest('hex');
return path.join(CACHE_DIR, `${hash}.webp`);
}
app.get('/preview', async (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).json({ error: 'url parameter required' });
const cachePath = getCachePath(url);
// Check cache
if (fs.existsSync(cachePath)) {
const stats = fs.statSync(cachePath);
if (Date.now() - stats.mtimeMs < CACHE_TTL) {
res.set('Content-Type', 'image/webp');
res.set('Cache-Control', 'public, max-age=86400');
res.set('X-Cache', 'HIT');
return res.sendFile(path.resolve(cachePath));
}
}
// Generate screenshot
try {
const screenshotUrl = `${SCREENSHOT_API}?url=${encodeURIComponent(url)}&width=800&height=600&format=webp`;
const response = await fetch(screenshotUrl);
if (!response.ok) {
return res.status(502).json({ error: 'Screenshot failed' });
}
const buffer = await response.buffer();
fs.writeFileSync(cachePath, buffer);
res.set('Content-Type', 'image/webp');
res.set('Cache-Control', 'public, max-age=86400');
res.set('X-Cache', 'MISS');
res.send(buffer);
} catch (err) {
res.status(500).json({ error: 'Internal error' });
}
});
Python Version (Flask)
import hashlib
import os
import time
import requests
from flask import Flask, request, send_file, jsonify
app = Flask(__name__)
SCREENSHOT_API = "https://hermesforge.dev/api/screenshot"
CACHE_DIR = "./cache"
CACHE_TTL = 86400 # 24 hours
os.makedirs(CACHE_DIR, exist_ok=True)
def cache_path(url):
h = hashlib.md5(url.encode()).hexdigest()
return os.path.join(CACHE_DIR, f"{h}.webp")
@app.route("/preview")
def preview():
url = request.args.get("url")
if not url:
return jsonify(error="url parameter required"), 400
path = cache_path(url)
# Check cache
if os.path.exists(path):
if time.time() - os.path.getmtime(path) < CACHE_TTL:
return send_file(path, mimetype="image/webp")
# Generate screenshot
resp = requests.get(SCREENSHOT_API, params={
"url": url, "width": 800, "height": 600, "format": "webp"
}, timeout=30)
if resp.status_code != 200:
return jsonify(error="Screenshot failed"), 502
with open(path, "wb") as f:
f.write(resp.content)
return send_file(path, mimetype="image/webp")
if __name__ == "__main__":
app.run(port=3000)
Embedding Previews in HTML
Once your service is running, embed previews anywhere:
<!-- Simple image embed -->
<img src="/preview?url=https://github.com" alt="GitHub preview" loading="lazy" />
<!-- Link preview card -->
<a href="https://github.com" class="link-card">
<img src="/preview?url=https://github.com" alt="Preview" />
<div class="link-card-title">GitHub</div>
</a>
<!-- CSS for the card -->
<style>
.link-card {
display: inline-block;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
width: 300px;
text-decoration: none;
color: inherit;
}
.link-card img {
width: 100%;
height: 180px;
object-fit: cover;
}
.link-card-title {
padding: 8px 12px;
font-weight: 600;
}
</style>
Use Cases
| Platform | Preview Size | Format |
|---|---|---|
| Chat app | 400x300 | WebP |
| Link directory | 800x600 | WebP |
| Dashboard widget | 1280x720 | PNG |
| Email newsletter | 600x400 | PNG |
| Social media card | 1200x630 | PNG |
Adjust width and height parameters to match your layout.
Handling Edge Cases
Some pages need extra time to load JavaScript content:
# Add delay for JS-heavy pages
curl -o preview.webp "https://hermesforge.dev/api/screenshot?url=https://app.example.com&width=800&height=600&format=webp&delay=2000"
# Block ads and popups for cleaner previews
curl -o preview.webp "https://hermesforge.dev/api/screenshot?url=https://news-site.com&width=800&height=600&format=webp&block_ads=true"
Rate Limits
The screenshot API allows 5 requests per minute without an API key, 20 with a free key. With 24-hour caching, this is plenty for most link preview services — you'll only generate new screenshots for URLs you haven't seen recently.
Try it: curl -o preview.webp "https://hermesforge.dev/api/screenshot?url=https://github.com&width=800&height=600&format=webp"