Every time you paste a URL into Slack, Discord, or Twitter, it magically shows a rich preview card with a title, description, and image. Ever wondered how that works?
In this tutorial, we'll build a link preview API that does exactly that — extract metadata from any URL and generate visual previews. No Puppeteer, no Chrome, no headless browsers on your server.
What We're Building
A Node.js service that:
- Takes any URL as input
- Extracts Open Graph / Twitter Card metadata (title, description, image)
- Falls back to HTML
<title>and<meta>tags when OG tags are missing - Generates a screenshot thumbnail as backup
- Returns structured JSON ready for rendering
The Approach
Instead of running a full browser locally (expensive, slow, breaks often), we'll use two API endpoints:
- Web Scraper API — fetches and parses HTML, returns structured content
- Screenshot API — generates a visual thumbnail of the page
Both are free to use with an API key from Frostbyte Gateway.
Step 1: Set Up the Project
mkdir link-preview && cd link-preview
npm init -y
Create preview.js:
const API_KEY = process.env.API_KEY || 'your-api-key';
const BASE = 'https://api.frostbyte.world';
async function extractMetadata(url) {
const res = await fetch(`${BASE}/v1/agent-scraper/scrape`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
},
body: JSON.stringify({ url, format: 'markdown' }),
});
if (!res.ok) throw new Error(`Scraper error: ${res.status}`);
return res.json();
}
function parseOGTags(html) {
const meta = {};
const patterns = {
title: /og:title["\s]+content="([^"]+)"/i,
description: /og:description["\s]+content="([^"]+)"/i,
image: /og:image["\s]+content="([^"]+)"/i,
siteName: /og:site_name["\s]+content="([^"]+)"/i,
type: /og:type["\s]+content="([^"]+)"/i,
};
for (const [key, regex] of Object.entries(patterns)) {
const match = html.match(regex);
if (match) meta[key] = match[1];
}
// Fallback to <title> tag
if (!meta.title) {
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (titleMatch) meta.title = titleMatch[1].trim();
}
// Fallback to meta description
if (!meta.description) {
const descMatch = html.match(
/name="description["\s]+content="([^"]+)"/i
);
if (descMatch) meta.description = descMatch[1];
}
return meta;
}
Step 2: Add Screenshot Thumbnails
When a page doesn't have an OG image, generate one:
async function getScreenshot(url) {
const params = new URLSearchParams({
url,
width: '1200',
height: '630',
format: 'png',
});
const res = await fetch(
`${BASE}/v1/agent-screenshot/screenshot?${params}`,
{ headers: { 'x-api-key': API_KEY } }
);
if (!res.ok) return null;
const data = await res.json();
return data.screenshot || data.url || null;
}
Step 3: Combine Into a Preview Endpoint
async function getLinkPreview(url) {
try {
// Validate URL
new URL(url);
} catch {
throw new Error('Invalid URL');
}
// Fetch page content
const scraped = await extractMetadata(url);
const html = scraped.content || scraped.markdown || '';
// Extract metadata
const meta = parseOGTags(html);
// Generate screenshot if no OG image
let thumbnail = meta.image || null;
if (!thumbnail) {
thumbnail = await getScreenshot(url);
}
// Extract domain for display
const domain = new URL(url).hostname.replace('www.', '');
return {
url,
domain,
title: meta.title || domain,
description: meta.description || null,
image: thumbnail,
siteName: meta.siteName || domain,
type: meta.type || 'website',
fetchedAt: new Date().toISOString(),
};
}
Step 4: Add a Simple HTTP Server
const http = require('http');
const server = http.createServer(async (req, res) => {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
if (req.method === 'OPTIONS') {
res.writeHead(200);
return res.end();
}
const parsed = new URL(req.url, 'http://localhost');
if (parsed.pathname === '/preview') {
const targetUrl = parsed.searchParams.get('url');
if (!targetUrl) {
res.writeHead(400);
return res.end(JSON.stringify({ error: 'Missing ?url= parameter' }));
}
try {
const preview = await getLinkPreview(targetUrl);
res.writeHead(200);
res.end(JSON.stringify(preview, null, 2));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: err.message }));
}
return;
}
res.writeHead(404);
res.end(JSON.stringify({ error: 'Use GET /preview?url=...' }));
});
server.listen(3000, () => console.log('Link preview API on :3000'));
Try It
# Start the server
API_KEY=your-key node preview.js
# Test with a URL
curl "http://localhost:3000/preview?url=https://github.com"
Response:
{
"url": "https://github.com",
"domain": "github.com",
"title": "GitHub: Let's build from here",
"description": "GitHub is where over 100 million developers shape the future of software.",
"image": "https://github.githubassets.com/assets/home-campfire-...",
"siteName": "GitHub",
"type": "website",
"fetchedAt": "2026-03-05T22:00:00.000Z"
}
Step 5: Add Caching
Link previews are expensive to generate but rarely change. Add a simple in-memory cache:
const cache = new Map();
const CACHE_TTL = 3600000; // 1 hour
async function getCachedPreview(url) {
const cached = cache.get(url);
if (cached && Date.now() - cached.time < CACHE_TTL) {
return { ...cached.data, cached: true };
}
const preview = await getLinkPreview(url);
cache.set(url, { data: preview, time: Date.now() });
return preview;
}
// Cleanup stale entries every 10 minutes
setInterval(() => {
const now = Date.now();
for (const [key, val] of cache) {
if (now - val.time > CACHE_TTL) cache.delete(key);
}
}, 600000);
Step 6: Batch Previews
Process multiple URLs at once (useful for chat apps rendering a page of messages):
async function batchPreview(urls) {
const results = await Promise.allSettled(
urls.map((url) => getCachedPreview(url))
);
return urls.map((url, i) => ({
url,
...(results[i].status === 'fulfilled'
? results[i].value
: { error: results[i].reason.message }),
}));
}
Real-World Use Cases
Chat applications — Render rich previews when users share links, just like Slack and Discord do.
CMS / Blog platforms — Auto-generate link cards for embedded URLs in articles.
Social media tools — Preview how a URL will appear when shared on Twitter, LinkedIn, or Facebook.
Bookmarking apps — Automatically capture title, description, and thumbnail when saving a URL.
SEO monitoring — Verify that your pages have correct OG tags before sharing.
Cost Breakdown
Each link preview costs 2 API credits (1 for scraping + 1 for screenshot). With 200 free credits, you get 100 link previews to start. That's enough to integrate and test before deciding if you need more.
What's Next
- Add Twitter Card metadata extraction (
twitter:title,twitter:image) - Support oEmbed for YouTube, Vimeo, Twitter embeds
- Add favicon extraction for site icons
- Build a React component that renders the preview cards
- Deploy to Vercel Edge Functions for global low-latency responses
Get a free API key at api-catalog-three.vercel.app — 200 credits, no credit card.
Top comments (0)