How to Build a Link Preview Service (Like Slack's URL Unfurling)
Build an API that takes any URL and returns a screenshot thumbnail, title, and description — the same thing Slack, Twitter, and iMessage do when you paste a link. One endpoint, under 50 lines.
When you paste a URL into Slack, it expands into a card with a title, description, and thumbnail image. That's a link preview service. Building one yourself — for a chat app, a bookmarking tool, a CMS, or an internal tool — requires fetching OG metadata and capturing a screenshot.
Here's the full service: one endpoint, returns metadata + screenshot thumbnail, under 50 lines of core logic.
The endpoint
POST /preview
{ "url": "https://example.com/article" }
→ {
"url": "https://example.com/article",
"title": "Article Title",
"description": "Meta description...",
"image": "https://your-cdn.com/previews/abc123.png",
"favicon": "https://example.com/favicon.ico",
"cachedAt": "2026-02-26T07:00:00Z"
}
Express implementation
import express from "express";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import crypto from "crypto";
const app = express();
app.use(express.json());
const s3 = new S3Client({ region: "us-east-1" });
const cache = new Map(); // use Redis in production
async function fetchMetadata(url) {
const res = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; LinkPreviewBot/1.0)" },
signal: AbortSignal.timeout(8000),
});
const html = await res.text();
const get = (pattern) => html.match(pattern)?.[1]?.trim() ?? null;
return {
title:
get(/<meta[^>]*property="og:title"[^>]*content="([^"]+)"/) ??
get(/<title[^>]*>([^<]+)<\/title>/) ??
new URL(url).hostname,
description:
get(/<meta[^>]*property="og:description"[^>]*content="([^"]+)"/) ??
get(/<meta[^>]*name="description"[^>]*content="([^"]+)"/) ??
null,
ogImage:
get(/<meta[^>]*property="og:image"[^>]*content="([^"]+)"/) ?? null,
favicon: `${new URL(url).origin}/favicon.ico`,
};
}
async function captureScreenshot(url) {
const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
method: "POST",
headers: {
"x-api-key": process.env.PAGEBOLT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
fullPage: false,
blockBanners: true,
blockAds: true,
blockChats: true,
}),
});
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
async function getOrCreatePreview(url) {
const cacheKey = crypto.createHash("md5").update(url).digest("hex");
// Check cache
if (cache.has(cacheKey)) {
const cached = cache.get(cacheKey);
const age = Date.now() - cached.cachedAt;
if (age < 24 * 60 * 60 * 1000) return cached; // 24h TTL
}
// Fetch metadata and screenshot in parallel
const [metadata, screenshot] = await Promise.all([
fetchMetadata(url),
captureScreenshot(url),
]);
// Upload screenshot to S3
const imageKey = `previews/${cacheKey}.png`;
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: imageKey,
Body: screenshot,
ContentType: "image/png",
CacheControl: "public, max-age=86400",
}));
const imageUrl = `https://${process.env.CLOUDFRONT_DOMAIN}/${imageKey}`;
const result = {
url,
title: metadata.title,
description: metadata.description,
image: imageUrl,
favicon: metadata.favicon,
ogImage: metadata.ogImage,
cachedAt: Date.now(),
};
cache.set(cacheKey, result);
return result;
}
app.post("/preview", async (req, res) => {
const { url } = req.body;
if (!url) return res.status(400).json({ error: "url required" });
// Basic URL validation
try { new URL(url); } catch {
return res.status(400).json({ error: "Invalid URL" });
}
// Block internal URLs
const hostname = new URL(url).hostname;
if (["localhost", "127.0.0.1", "0.0.0.0"].includes(hostname)) {
return res.status(400).json({ error: "Internal URLs not allowed" });
}
try {
const preview = await getOrCreatePreview(url);
res.json(preview);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
Production: Redis cache
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
const CACHE_TTL = 60 * 60 * 24; // 24 hours
async function getCached(key) {
const raw = await redis.get(`preview:${key}`);
return raw ? JSON.parse(raw) : null;
}
async function setCache(key, value) {
await redis.setex(`preview:${key}`, CACHE_TTL, JSON.stringify(value));
}
Rate limiting
import rateLimit from "express-rate-limit";
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 requests per minute per IP
message: { error: "Too many requests" },
});
app.use("/preview", limiter);
Use in a React chat app
// hooks/useLinkPreview.ts
export function useLinkPreview(url: string | null) {
const [preview, setPreview] = useState<LinkPreview | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!url) return;
setLoading(true);
fetch("/api/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
})
.then((r) => r.json())
.then(setPreview)
.finally(() => setLoading(false));
}, [url]);
return { preview, loading };
}
// In your message component
function MessageBubble({ message }: { message: Message }) {
const urlMatch = message.text.match(/https?:\/\/[^\s]+/);
const { preview } = useLinkPreview(urlMatch?.[0] ?? null);
return (
<div className="message">
<p>{message.text}</p>
{preview && (
<div className="link-preview">
{preview.image && <img src={preview.image} alt="" />}
<div className="meta">
<div className="title">{preview.title}</div>
{preview.description && <div className="desc">{preview.description}</div>}
<div className="domain">{new URL(preview.url).hostname}</div>
</div>
</div>
)}
</div>
);
}
Slack-style unfurl for your own bot
// In your Slack bot's link_shared event handler
app.event("link_shared", async ({ event, client }) => {
const unfurls = {};
for (const link of event.links) {
try {
const preview = await getOrCreatePreview(link.url);
unfurls[link.url] = {
title: preview.title,
text: preview.description ?? "",
image_url: preview.image,
footer: new URL(link.url).hostname,
};
} catch {
// Skip links that fail
}
}
if (Object.keys(unfurls).length > 0) {
await client.chat.unfurl({
channel: event.channel,
ts: event.message_ts,
unfurls,
});
}
});
Security: block SSRF
Always validate URLs before passing them to the screenshot API — block internal network ranges:
import dns from "dns/promises";
import { isIP } from "net";
async function isSafeUrl(urlStr) {
const url = new URL(urlStr);
// Block non-http(s)
if (!["http:", "https:"].includes(url.protocol)) return false;
// Block localhost variants
const blockedHosts = ["localhost", "127.0.0.1", "0.0.0.0", "::1"];
if (blockedHosts.includes(url.hostname)) return false;
// Resolve hostname and block private IP ranges
if (!isIP(url.hostname)) {
try {
const { address } = await dns.lookup(url.hostname);
if (/^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.)/.test(address)) {
return false;
}
} catch {
return false;
}
}
return true;
}
The core service — metadata fetch + screenshot + S3 upload + cache — is about 50 lines. The screenshot is what makes it genuinely useful: a thumbnail of what the page actually looks like, not just the OG image (which may be missing, wrong size, or outdated).
Try it free
100 requests/month, no credit card required. OG images, screenshots, PDFs, and video — one API.
Get API Key — Free