Back to Blog

How to Generate Website Thumbnails at Scale

8 min readMarch 14, 2026

Website thumbnails are everywhere: link previews in chat apps, website directories, social media cards, search results, and portfolio galleries. If you need to generate these at scale -- for hundreds or thousands of URLs -- doing it manually is not an option. This guide covers how to automate website thumbnail generation using APIs and self-hosted solutions.

Why Generate Website Thumbnails?

Thumbnails provide visual context that plain text links cannot. Studies show that links with preview images get 2-3x higher click-through rates. Common use cases include:

Approach 1: Screenshot API with Resize

The simplest method: capture a full-resolution screenshot and resize it to thumbnail dimensions. Most screenshot APIs support custom viewport sizes, which effectively creates thumbnails.

const API_KEY = process.env.SCREENSHOT_API_KEY;
const BASE_URL = 'https://screenshotapi-api-production.up.railway.app';

async function generateThumbnail(url, width = 400, height = 300) {
  // Capture at a standard viewport, the API handles the rest
  const params = new URLSearchParams({
    url,
    width: '1280',
    height: '800',
    format: 'webp',   // WebP for smallest file size
    quality: '75',    // Good enough for thumbnails
  });

  const response = await fetch(`${BASE_URL}/v1/screenshot?${params}`, {
    headers: { 'Authorization': `Bearer ${API_KEY}` }
  });

  if (!response.ok) throw new Error(`Failed: ${response.statusText}`);
  return Buffer.from(await response.arrayBuffer());
}

// Generate thumbnails for a list of URLs
const urls = [
  'https://github.com',
  'https://vercel.com',
  'https://stripe.com',
  'https://tailwindcss.com',
];

const thumbnails = await Promise.all(
  urls.map(async (url) => ({
    url,
    image: await generateThumbnail(url),
  }))
);

// Save or upload to your CDN
thumbnails.forEach(({ url, image }) => {
  const slug = new URL(url).hostname.replace(/\./g, '-');
  fs.writeFileSync(`thumbnails/${slug}.webp`, image);
});

Approach 2: Batch Processing Pipeline

For large-scale thumbnail generation (thousands of URLs), you need a pipeline that handles rate limiting, retries, and caching. Here is a production-ready example:

const pLimit = require('p-limit');
const fs = require('fs');
const path = require('path');

const API_KEY = process.env.SCREENSHOT_API_KEY;
const BASE_URL = 'https://screenshotapi-api-production.up.railway.app';
const CACHE_DIR = './thumbnail-cache';

// Limit concurrent requests to avoid rate limits
const limit = pLimit(5);

async function generateWithRetry(url, retries = 3) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const params = new URLSearchParams({
        url,
        width: '1280',
        height: '800',
        format: 'webp',
        quality: '75',
      });

      const response = await fetch(`${BASE_URL}/v1/screenshot?${params}`, {
        headers: { 'Authorization': `Bearer ${API_KEY}` }
      });

      if (response.status === 429) {
        // Rate limited - wait and retry
        const retryAfter = parseInt(response.headers.get('retry-after') || '5');
        console.log(`  Rate limited, waiting ${retryAfter}s...`);
        await new Promise(r => setTimeout(r, retryAfter * 1000));
        continue;
      }

      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return Buffer.from(await response.arrayBuffer());
    } catch (err) {
      if (attempt === retries) throw err;
      await new Promise(r => setTimeout(r, 1000 * attempt));
    }
  }
}

async function processBatch(urls) {
  fs.mkdirSync(CACHE_DIR, { recursive: true });

  const results = await Promise.allSettled(
    urls.map(url => limit(async () => {
      const slug = new URL(url).hostname.replace(/[^a-z0-9]/gi, '-');
      const cachePath = path.join(CACHE_DIR, `${slug}.webp`);

      // Skip if cached
      if (fs.existsSync(cachePath)) {
        console.log(`  CACHED: ${url}`);
        return { url, status: 'cached', path: cachePath };
      }

      const image = await generateWithRetry(url);
      fs.writeFileSync(cachePath, image);
      console.log(`  OK: ${url} (${image.length} bytes)`);
      return { url, status: 'ok', path: cachePath };
    }))
  );

  const ok = results.filter(r => r.status === 'fulfilled').length;
  const failed = results.filter(r => r.status === 'rejected').length;
  console.log(`\nDone: ${ok} succeeded, ${failed} failed`);
  return results;
}

// Usage
const urls = fs.readFileSync('urls.txt', 'utf-8')
  .split('\n')
  .filter(Boolean);

processBatch(urls);

Approach 3: On-Demand with CDN Caching

Instead of pre-generating all thumbnails, generate them on demand when first requested and cache them on a CDN. This works well when you have many URLs but only some are frequently accessed.

// Express endpoint that generates thumbnails on demand
const express = require('express');
const crypto = require('crypto');

const app = express();

app.get('/thumbnail', async (req, res) => {
  const { url } = req.query;
  if (!url) return res.status(400).json({ error: 'url required' });

  // Generate cache key from URL
  const cacheKey = crypto
    .createHash('md5')
    .update(url)
    .digest('hex');

  // Check memory/Redis cache first
  const cached = await cache.get(cacheKey);
  if (cached) {
    res.set('Content-Type', 'image/webp');
    res.set('Cache-Control', 'public, max-age=86400');
    return res.send(cached);
  }

  // Generate thumbnail via ScreenshotAPI
  const params = new URLSearchParams({
    url, format: 'webp', width: '1280', height: '800', quality: '75',
  });

  const response = await fetch(
    `https://screenshotapi-api-production.up.railway.app/v1/screenshot?${params}`,
    { headers: { 'Authorization': `Bearer ${process.env.API_KEY}` } }
  );

  const buffer = Buffer.from(await response.arrayBuffer());

  // Cache for 24 hours
  await cache.set(cacheKey, buffer, 86400);

  res.set('Content-Type', 'image/webp');
  res.set('Cache-Control', 'public, max-age=86400');
  res.send(buffer);
});

Choosing the Right Format

FormatFile SizeQualityBrowser SupportBest For
WebPSmallestExcellent97%+Default choice for thumbnails
JPEGSmallGood100%Maximum compatibility
PNGLargePerfect100%Transparency needed

For thumbnails, WebP at 75% quality gives the best balance of file size and visual quality. A typical 400x300 thumbnail is 15-25KB in WebP versus 40-60KB in JPEG.

Performance Tips

  1. Use WebP format. 25-35% smaller than JPEG at the same quality. Browser support is over 97% globally.
  2. Lower quality for thumbnails. At small sizes, quality 70-80 is visually indistinguishable from 100. Save bandwidth and storage.
  3. Batch with concurrency limits. Run 5-10 screenshots in parallel, not 100. Respect API rate limits.
  4. Cache aggressively. Website thumbnails rarely change. Cache for 24-48 hours minimum, invalidate only when needed.
  5. Use a CDN. Serve cached thumbnails from edge locations close to your users. Cloudflare R2 + Workers is a cost-effective option.

Frequently Asked Questions

How many thumbnails can I generate per month?

With ScreenshotAPI's free tier, you get 100 screenshots/month. The Pro plan ($29/mo) gives you 10,000 -- enough for most directory and link preview use cases.

Can I resize thumbnails after capture?

Yes. Capture at a standard viewport (1280x800) and resize client-side or with an image processing service. Alternatively, capture at a smaller viewport for a direct thumbnail.

How do I handle sites that block screenshots?

ScreenshotAPI uses realistic browser fingerprints that work with most sites. For heavily protected pages, try adding a wait parameter (2-5 seconds) to let the page fully render.

Related Articles

Generate thumbnails in seconds

Try ScreenshotAPI's interactive playground -- capture your first thumbnail with no signup.

Open Playground