On-demand thumbnails with Vercel Edge Functions
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
- Extract thumbnails from video β task options and format choices
- Process uploads in Next.js β full upload-to-processing pipeline
- Responsive images β generate multiple sizes from a single source