How to Build a Link Preview Service With a Free Screenshot API

2026-04-23 | Tags: ["screenshot", "api", "link-preview", "automation", "web"]

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.

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"