Ryan Trann
December 25, 2025
5 min read

Thumbnails improve engagement and UX, help build feeds and preview grids, and provide fallbacks when video processing isn't done. If your product accepts video, you're going to need a way to generate thumbnails.
There are a few options to generate thumbnails. This guide shows the practical paths that actually work, including Node.js with FFmpeg, worker based generation, and browser side extraction.
This guide will focus on how to generate video thumbnails in Node.js with FFmpeg. Video decoding requires a real runtime and the most ubiquitous option FFmpeg doesn't run everywhere.
The examples below target Node capable environments.
ffmpeg-static + fluent-ffmpegffmpeg-static bundles FFmpeg as an npm package providing binaries that work on different architectures (darwin, linux, win32). fluent-ffmpeg is a thin JavaScript wrapper that builds and runs FFmpeg commands without shell scripting (FFmpeg is originally a CLI tool).
Together, they let you generate thumbnails in pure Node environments (Next.js, NestJS, workers etc) with minimal setup.
Install dependencies
bashnpm install ffmpeg-static fluent-ffmpegGenerate thumbnail from local file
tsximport ffmpeg from 'fluent-ffmpeg';
import ffmpegPath from 'ffmpeg-static';
import path from 'path';
import fs from 'fs/promises';
export async function generateThumbnail(inputPath: string, outputDir: string) {
await fs.mkdir(outputDir, { recursive: true });
const outputPath = path.join(outputDir, 'thumb.jpg');
return new Promise<string>((resolve, reject) => {
ffmpeg(inputPath)
.setFfmpegPath(ffmpegPath!)
.on('end', () => resolve(outputPath))
.on('error', reject)
.screenshots({
timestamps: ["1"],
filename: 'thumb.jpg',
folder: outputDir,
});
});
}Notes:
timestamps: You can enter one or more second value(s) or percentage(s) for determining where the thumbnail is taken from. Just make sure there's actually content at that time or you'll get a blank image.size: Omitting the size param creates the thumbnail at the original video size, or you can specify a new size like 640x? which retains the original aspect ratio.Common issues: Timestamp errors, corrupted metadata, or no tmp space in serverless functions.
If you're storing videos in a Storage Bucket (S3, Supabase, Firebase, etc), you ideally don't want to process them inside the API call server side when uploading. Instead, run thumbnail generation asynchronously in a worker after the upload completes. That worker can be a separate service, a containerized job, or a background process. Anything that can fetch the video, run FFmpeg, and upload the thumbnail.
Video metadata record
tsx{
id: 'video_123',
originalUrl: 'https://s3.../original.mp4',
status: 'processing'
}Fetch video from storage
tsxconst res = await fetch(originalUrl);
const arrayBuffer = await res.arrayBuffer();
// Write to /tmp or stream directlyFFmpeg with raw options
tsxffmpeg()
.input(stream)
.inputOptions(['-ss 00:00:01'])
.setFfmpegPath(ffmpegPath)
.outputOptions(['-frames:v 1'])
.output('thumb.jpg')
.run();What the flags do:
-ss 00:00:01 tells FFmpeg to seek to the 1-second mark before decoding. This avoids a potential blank frame that may exist at the start. You can adjust to wherever your video starts.-frames:v 1 instructs FFmpeg to decode exactly one frame and stop.Why this method instead of .screenshots() that we used in approach 1?
.screenshots() assumes the video is a seekable local file.inputOptions + outputOptions) avoids temp file creation and is more predictable in worker environments.Upload to S3
tsximport { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import fs from 'fs/promises';
const s3 = new S3Client({ region: 'us-west-2' });
await s3.send(
new PutObjectCommand({
Bucket: 'my-bucket',
Key: `videos/${id}/thumb.jpg`,
Body: await fs.readFile('thumb.jpg'),
ContentType: 'image/jpeg'
})
);Useful for client UX that requires preview images before upload.
Browser-side thumbnail extraction
tsxexport async function extractThumbnail(file: File): Promise<Blob> {
const video = document.createElement('video');
video.src = URL.createObjectURL(file);
video.preload = 'metadata';
await new Promise(res => video.onloadedmetadata = res);
video.currentTime = video.duration / 2;
await new Promise(res => video.onseeked = res);
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(video, 0, 0);
return new Promise(resolve =>
canvas.toBlob(blob => resolve(blob!), 'image/jpeg', 0.90)
);
}Pros: Instant UX, zero backend load.
Cons: Browser memory limits, inconsistencies, not usable on workers.
ffmpeg-static to generate video thumbnails server-side.Some apps use both client side and server side thumbnail generation to provide the best UX.
If you'd rather skip the setup entirely, Hyperserve can generate thumbnails automatically on every upload. You don't have to setup workers, Lambda layers or FFmpeg. We handle the architecture complexity, just upload a video to our API and get a thumbnail URL back. That's why we built Hyperserve—to make adding video to your app as simple as possible.
Video Hosting API made simple.
© 2025 Hyperserve. All rights reserved.