Back to Blog
Guide February 26, 2026 · 5 min read

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