DEV Community

Cover image for Build a Link Preview Service in 5 Minutes with a Screenshot API
Alex Silverberg
Alex Silverberg

Posted on

Build a Link Preview Service in 5 Minutes with a Screenshot API

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

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

What You'll Need

  • Node.js 18+ (or Python 3.8+, I'll show both)
  • A free SnapAPI keysnapapi.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
Enter fullscreen mode Exit fullscreen mode

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

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

Run it:

node server.js
Enter fullscreen mode Exit fullscreen mode

Test it:

curl "http://localhost:3000/preview?url=https://github.com" | jq
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
pip install flask requests
SNAPAPI_KEY=sk_live_xxx python server.py
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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)              │
│                                                  │
└─────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)