Allscreenshots Docs
Guides

Build a link preview service

Create rich previews for URLs shared in your application

Build a link preview service

Create a service that generates rich previews for URLs shared in chat applications, social platforms, or content management systems.

What you'll build

A link preview service that returns:

  • Title and description from meta tags
  • A thumbnail screenshot of the page
  • Favicon and site name
{
  "url": "https://example.com/article",
  "title": "Example Article",
  "description": "This is an example article...",
  "siteName": "Example Site",
  "favicon": "https://example.com/favicon.ico",
  "thumbnail": "https://storage.allscreenshots.com/preview-abc123.png"
}

Architecture

┌──────────┐     ┌──────────────┐     ┌─────────────────┐
│  Client  │ --> │  Your API    │ --> │  AllScreenshots │
└──────────┘     │  (cache +    │     │      API        │
                 │   metadata)  │     └─────────────────┘
                 └──────────────┘

Implementation

Create the API endpoint

// api/preview/route.js
import { parse } from 'node-html-parser';

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const url = searchParams.get('url');

  if (!url) {
    return Response.json({ error: 'URL required' }, { status: 400 });
  }

  // Check cache first
  const cacheKey = `preview:${url}`;
  const cached = await cache.get(cacheKey);
  if (cached) {
    return Response.json(cached);
  }

  // Generate preview
  const preview = await generatePreview(url);

  // Cache for 24 hours
  await cache.set(cacheKey, preview, { ttl: 86400 });

  return Response.json(preview);
}

Fetch metadata

async function fetchMetadata(url) {
  try {
    const response = await fetch(url, {
      headers: {
        'User-Agent': 'Mozilla/5.0 (compatible; LinkPreviewBot/1.0)',
      },
    });

    const html = await response.text();
    const root = parse(html);

    // Extract Open Graph tags
    const og = (property) =>
      root.querySelector(`meta[property="og:${property}"]`)?.getAttribute('content');

    // Extract Twitter tags as fallback
    const twitter = (name) =>
      root.querySelector(`meta[name="twitter:${name}"]`)?.getAttribute('content');

    // Extract standard meta tags
    const meta = (name) =>
      root.querySelector(`meta[name="${name}"]`)?.getAttribute('content');

    return {
      title: og('title') || twitter('title') || root.querySelector('title')?.text || '',
      description: og('description') || twitter('description') || meta('description') || '',
      siteName: og('site_name') || new URL(url).hostname,
      image: og('image') || twitter('image') || null,
      favicon: root.querySelector('link[rel="icon"]')?.getAttribute('href') ||
               root.querySelector('link[rel="shortcut icon"]')?.getAttribute('href') ||
               `${new URL(url).origin}/favicon.ico`,
    };
  } catch (error) {
    console.error('Failed to fetch metadata:', error);
    return {
      title: new URL(url).hostname,
      description: '',
      siteName: new URL(url).hostname,
      image: null,
      favicon: null,
    };
  }
}

Capture thumbnail

async function captureThumbnail(url) {
  const response = await fetch('https://api.allscreenshots.com/v1/screenshots', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.ALLSCREENSHOTS_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url,
      viewport: {
        width: 1200,
        height: 630, // OG image dimensions
      },
      format: 'png',
      quality: 80,
      waitUntil: 'networkidle',
      blockAds: true,
      blockCookieBanners: true,
      responseType: 'json',
    }),
  });

  if (!response.ok) {
    throw new Error('Failed to capture screenshot');
  }

  const result = await response.json();
  return result.url;
}

Combine into preview

async function generatePreview(url) {
  // Fetch metadata and thumbnail in parallel
  const [metadata, thumbnail] = await Promise.all([
    fetchMetadata(url),
    captureThumbnail(url).catch(() => null),
  ]);

  return {
    url,
    title: metadata.title,
    description: metadata.description,
    siteName: metadata.siteName,
    favicon: metadata.favicon,
    // Use OG image if available, fall back to screenshot
    thumbnail: metadata.image || thumbnail,
  };
}

Frontend component

function LinkPreview({ url }) {
  const { data: preview, isLoading, error } = useQuery(
    ['preview', url],
    () => fetch(`/api/preview?url=${encodeURIComponent(url)}`).then(r => r.json()),
    { staleTime: 86400000 } // Cache for 24 hours
  );

  if (isLoading) {
    return (
      <div className="link-preview loading">
        <div className="skeleton-image" />
        <div className="skeleton-text" />
      </div>
    );
  }

  if (error || !preview) {
    return (
      <a href={url} className="link-preview fallback">
        {url}
      </a>
    );
  }

  return (
    <a href={url} className="link-preview" target="_blank" rel="noopener">
      {preview.thumbnail && (
        <img
          src={preview.thumbnail}
          alt={preview.title}
          className="preview-image"
        />
      )}
      <div className="preview-content">
        <div className="preview-site">
          {preview.favicon && (
            <img src={preview.favicon} alt="" className="favicon" />
          )}
          <span>{preview.siteName}</span>
        </div>
        <h3 className="preview-title">{preview.title}</h3>
        {preview.description && (
          <p className="preview-description">{preview.description}</p>
        )}
      </div>
    </a>
  );
}

Styling

.link-preview {
  display: flex;
  flex-direction: column;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  overflow: hidden;
  text-decoration: none;
  color: inherit;
  max-width: 400px;
}

.link-preview:hover {
  border-color: #ccc;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.preview-image {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.preview-content {
  padding: 12px;
}

.preview-site {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: #666;
  margin-bottom: 4px;
}

.favicon {
  width: 16px;
  height: 16px;
}

.preview-title {
  font-size: 16px;
  font-weight: 600;
  margin: 0 0 4px;
  line-height: 1.3;
}

.preview-description {
  font-size: 14px;
  color: #666;
  margin: 0;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

Optimizations

Queue processing

For high-volume applications, queue preview generation:

import { Queue } from 'bullmq';

const previewQueue = new Queue('link-previews');

// API endpoint adds to queue
app.get('/api/preview', async (req, res) => {
  const { url } = req.query;

  // Check cache
  const cached = await cache.get(`preview:${url}`);
  if (cached) {
    return res.json(cached);
  }

  // Check if already processing
  const existing = await previewQueue.getJob(url);
  if (existing) {
    // Wait for existing job
    const result = await existing.waitUntilFinished(previewQueue.queueEvents);
    return res.json(result);
  }

  // Add to queue
  const job = await previewQueue.add('generate', { url }, { jobId: url });
  const result = await job.waitUntilFinished(previewQueue.queueEvents);

  res.json(result);
});

// Worker processes queue
const worker = new Worker('link-previews', async (job) => {
  const preview = await generatePreview(job.data.url);
  await cache.set(`preview:${job.data.url}`, preview, { ttl: 86400 });
  return preview;
});

Bulk preview generation

When users paste multiple links:

async function generateBulkPreviews(urls) {
  // First, check cache for all URLs
  const cached = await Promise.all(
    urls.map(url => cache.get(`preview:${url}`))
  );

  const uncachedUrls = urls.filter((_, i) => !cached[i]);

  if (uncachedUrls.length === 0) {
    return urls.map((url, i) => cached[i]);
  }

  // Generate screenshots for uncached URLs
  const bulkResponse = await fetch('https://api.allscreenshots.com/v1/screenshots/bulk', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.ALLSCREENSHOTS_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      urls: uncachedUrls.map(url => ({ url })),
      defaults: {
        viewport: { width: 1200, height: 630 },
        format: 'png',
        blockAds: true,
        blockCookieBanners: true,
      },
    }),
  });

  // Fetch metadata in parallel
  const metadataResults = await Promise.all(
    uncachedUrls.map(url => fetchMetadata(url))
  );

  // Wait for bulk job to complete
  const { bulkJobId } = await bulkResponse.json();
  const screenshotResults = await waitForBulkJob(bulkJobId);

  // Combine and cache results
  const newPreviews = uncachedUrls.map((url, i) => ({
    url,
    ...metadataResults[i],
    thumbnail: screenshotResults.jobs[i]?.result?.url || null,
  }));

  await Promise.all(
    newPreviews.map(preview =>
      cache.set(`preview:${preview.url}`, preview, { ttl: 86400 })
    )
  );

  // Merge cached and new results
  let newIndex = 0;
  return urls.map((url, i) => {
    if (cached[i]) return cached[i];
    return newPreviews[newIndex++];
  });
}

Best practices

Always cache previews aggressively. Most link content doesn't change frequently.

Rate limiting

Protect your endpoint from abuse:

import rateLimit from 'express-rate-limit';

const previewLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 30, // 30 requests per minute
  message: { error: 'Too many requests' },
});

app.use('/api/preview', previewLimiter);

URL validation

Validate and sanitize input URLs:

function validateUrl(url) {
  try {
    const parsed = new URL(url);

    // Only allow HTTP(S)
    if (!['http:', 'https:'].includes(parsed.protocol)) {
      return null;
    }

    // Block private IPs
    if (isPrivateIP(parsed.hostname)) {
      return null;
    }

    return parsed.href;
  } catch {
    return null;
  }
}

Error handling

Gracefully handle failures:

async function generatePreview(url) {
  const preview = {
    url,
    title: new URL(url).hostname,
    description: null,
    siteName: new URL(url).hostname,
    favicon: null,
    thumbnail: null,
    error: null,
  };

  try {
    const metadata = await fetchMetadata(url);
    Object.assign(preview, metadata);
  } catch (e) {
    preview.error = 'metadata_failed';
  }

  try {
    preview.thumbnail = await captureThumbnail(url);
  } catch (e) {
    preview.error = preview.error || 'thumbnail_failed';
  }

  return preview;
}

On this page