On-demand thumbnails with Vercel Edge Functions

View Markdown

Instead of pre-generating every thumbnail at upload time, serve them on demand. An Edge Function accepts a video URL and timestamp, checks Vercel KV for a cached result, and only calls Ittybit on a cache miss. Subsequent requests for the same frame return instantly.

Create the thumbnail task

The Edge Function dispatches an Ittybit task with kind: "image", passing the video URL as input and the desired frame time as start.

const res = await fetch("https://api.ittybit.com/jobs", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ITTYBIT_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    input: "https://cdn.example.com/videos/interview.mp4",
    kind: "image",
    options: {
      start: 30,
      width: 640,
      height: 360,
      format: "webp",
    },
  }),
});
const { id, status } = await res.json();
// id: "task_abc123", status: "pending"
curl -X POST https://api.ittybit.com/jobs \
  -H "Authorization: Bearer $ITTYBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "https://cdn.example.com/videos/interview.mp4",
    "kind": "image",
    "options": {
      "start": 30,
      "width": 640,
      "height": 360,
      "format": "webp"
    }
  }'

The task returns immediately with status: "pending". The Edge Function polls until processing completes, then caches the output URL.

Edge Function

Deploy this as app/api/thumbnail/route.ts. A request to /api/thumbnail?url=https://cdn.example.com/videos/interview.mp4&t=30 returns a redirect to the cached thumbnail.

// app/api/thumbnail/route.ts
import { kv } from '@vercel/kv';

export const runtime = 'edge';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const url = searchParams.get('url');
  const t = searchParams.get('t') ?? '0';

  if (!url) {
    return new Response('Missing url parameter', { status: 400 });
  }

  // Build a stable cache key from the video URL and timestamp
  const cacheKey = `thumb:${url}:${t}`;

  // Check KV for a cached thumbnail URL
  const cached = await kv.get<string>(cacheKey);
  if (cached) {
    return Response.redirect(cached, 302);
  }

  // Cache miss β€” dispatch an Ittybit task
  const taskRes = await fetch('https://api.ittybit.com/jobs', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.ITTYBIT_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      input: url,
      kind: 'image',
      options: {
        start: parseFloat(t),
        width: 640,
        height: 360,
        format: 'webp',
      },
    }),
  });

  if (!taskRes.ok) {
    return new Response('Failed to create task', { status: 502 });
  }

  const task = await taskRes.json();
  const thumbnailUrl = await pollForResult(task.id);

  if (!thumbnailUrl) {
    return new Response('Thumbnail generation timed out', { status: 504 });
  }

  // Cache for 7 days
  await kv.set(cacheKey, thumbnailUrl, { ex: 60 * 60 * 24 * 7 });

  return Response.redirect(thumbnailUrl, 302);
}

async function pollForResult(taskId: string): Promise<string | null> {
  const maxAttempts = 20;
  const interval = 1500;

  for (let i = 0; i < maxAttempts; i++) {
    await new Promise((r) => setTimeout(r, interval));

    const res = await fetch(`https://api.ittybit.com/jobs/${taskId}`, {
      headers: {
        Authorization: `Bearer ${process.env.ITTYBIT_API_KEY}`,
      },
    });

    if (!res.ok) continue;

    const task = await res.json();

    if (task.status === 'succeeded') {
      return task.output?.url ?? null;
    }

    if (task.status === 'failed') {
      return null;
    }
  }

  return null;
}

The first request for a given video+timestamp pair takes a few seconds while Ittybit extracts the frame. Every request after that resolves from KV in single-digit milliseconds.

Environment variables

Set these in your Vercel project settings or .env.local:

ITTYBIT_API_KEY=your_ittybit_api_key
KV_REST_API_URL=your_vercel_kv_url
KV_REST_API_TOKEN=your_vercel_kv_token

Custom sizes

Pass w and h query parameters to support multiple thumbnail dimensions. Extend the cache key to include them:

const w = parseInt(searchParams.get('w') ?? '640');
const h = parseInt(searchParams.get('h') ?? '360');
const cacheKey = `thumb:${url}:${t}:${w}x${h}`;

Then use w and h in the task options:

{ "start": 30, "width": 640, "height": 360, "format": "webp" }

See also