DEV Community

Ozor
Ozor

Posted on

How to Build a Link Preview API in JavaScript (Like Slack & Discord)

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:

  1. Takes any URL as input
  2. Extracts Open Graph / Twitter Card metadata (title, description, image)
  3. Falls back to HTML <title> and <meta> tags when OG tags are missing
  4. Generates a screenshot thumbnail as backup
  5. 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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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(),
  };
}
Enter fullscreen mode Exit fullscreen mode

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'));
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 }),
  }));
}
Enter fullscreen mode Exit fullscreen mode

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)