Skip to content
Docs

How to process user-uploaded files with Vercel Sandbox and Vercel Blob

Learn how to upload files to Vercel Blob, process them safely with FFmpeg in an isolated Vercel Sandbox, and store the results back in Blob.

Ben SabicContent Engineer

Processing user-uploaded media, such as generating video thumbnails or transcoding files, requires binaries like FFmpeg and unpredictable amounts of compute. User uploads are also untrusted input: media parsers have a long history of vulnerabilities, so you don't want them running next to your production code.

Vercel Sandbox runs each job in an isolated Firecracker microVM, where you can install any binary, while Vercel Blob stores files durably at both ends of the pipeline. Together, they give you a complete upload-process-store workflow without requiring you to manage any servers.

Copy link to headingOverview

In this guide, you'll learn how to:

  • Upload files directly from the browser to Vercel Blob
  • Build a sandbox snapshot with FFmpeg preinstalled
  • Process uploaded files with FFmpeg in a Vercel Sandbox created from that snapshot
  • Store the processed output back in Vercel Blob and return its URL

Copy link to headingPrerequisites

Before you begin, make sure you have:

  • Node.js 22+ and a package manager (e.g., pnpm)
  • A Vercel account
  • The @vercel/blob and @vercel/sandbox packages installed:
Terminal
pnpm i @vercel/blob @vercel/sandbox
Terminal
npm i @vercel/blob @vercel/sandbox
Terminal
yarn add @vercel/blob @vercel/sandbox
Terminal
bun add @vercel/blob @vercel/sandbox

This guide uses Next.js App Router, but both SDKs work with virtually any framework.

Copy link to headingHow it works

The pipeline has three stages, each handing off to the next:

  1. The browser uploads the file directly to Vercel Blob using a client upload. The file never passes through your server, so you can accept files larger than the 4.5 MB request body limit for server uploads.
  2. Your API route creates a sandbox from a snapshot that already contains FFmpeg, downloads the file from its public Blob URL, and runs FFmpeg inside the microVM. The sandbox is fully isolated, so even a malicious media file can't affect your production systems.
  3. The route reads the processed output back from the sandbox, uploads it to Vercel Blob with put(), and returns the new URL to the browser.

You build the FFmpeg snapshot once with a setup script. Every processing job then boots from that snapshot, skipping the FFmpeg download and starting faster than a fresh sandbox. The sandbox filesystem is ephemeral, so Blob acts as the durable storage layer before and after processing.

Copy link to headingSteps

Copy link to heading1. Create the upload token route

Client uploads work via a token exchange: the browser requests a short-lived upload token from your server, then sends the file directly to Vercel Blob. Create a route that generates these tokens and restricts what users can upload:

app/api/upload/route.ts
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
import { NextResponse } from 'next/server';
export async function POST(request: Request): Promise<NextResponse> {
const body = (await request.json()) as HandleUploadBody;
try {
const jsonResponse = await handleUpload({
body,
request,
onBeforeGenerateToken: async (pathname) => {
// Authenticate the user here before generating a token
return {
allowedContentTypes: ['video/mp4', 'video/quicktime', 'video/webm'],
addRandomSuffix: true,
maximumSizeInBytes: 500 * 1024 * 1024, // 500 MB
};
},
onUploadCompleted: async ({ blob }) => {
// Optionally persist blob.url to your database here
console.log('Upload completed:', blob.url);
},
});
return NextResponse.json(jsonResponse);
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 400 },
);
}
}

The allowedContentTypes option rejects anything that isn't a video before the upload starts, and addRandomSuffix makes each blob URL unguessable.

Copy link to heading2. Build the upload page

Create a page that uploads the file to Blob, then sends the resulting URL to your processing route:

app/upload/page.tsx
'use client';
import { upload } from '@vercel/blob/client';
import { useRef, useState } from 'react';
export default function UploadPage() {
const inputFileRef = useRef<HTMLInputElement>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [status, setStatus] = useState<string>('');
async function handleSubmit() {
const file = inputFileRef.current?.files?.[0];
if (!file) return;
setStatus('Uploading...');
const blob = await upload(file.name, file, {
access: 'public',
handleUploadUrl: '/api/upload',
});
setStatus('Processing...');
const response = await fetch('/api/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: blob.url }),
});
if (!response.ok) {
setStatus('Processing failed');
return;
}
const result = await response.json();
setThumbnailUrl(result.thumbnailUrl);
setStatus('Done');
}
return (
<div>
<input type="file" ref={inputFileRef} accept="video/*" />
<button onClick={handleSubmit}>Upload and process</button>
<p>{status}</p>
{thumbnailUrl && <img src={thumbnailUrl} alt="Video thumbnail" />}
</div>
);
}

The upload() helper calls your token route from step 1 behind the scenes, then streams the file straight to Blob.

Copy link to heading3. Build a snapshot with FFmpeg installed

This guide's setup script pins the legacy node24 runtime, which runs Amazon Linux 2023 and doesn't ship FFmpeg in its default package repositories. (New SDK v3 sandboxes otherwise default to the Ubuntu-based vercel/sandbox/universal:latest managed image; the static FFmpeg build below works on either base.) Rather than downloading FFmpeg on every job, install it once in a setup sandbox, capture a snapshot, and create all future processing sandboxes from that snapshot.

Install the script dependencies, then create the setup script:

Terminal
pnpm i -D tsx dotenv
scripts/build-snapshot.ts
import { config } from 'dotenv';
config({ path: '.env.local' });
import { Sandbox } from '@vercel/sandbox';
async function main() {
const sandbox = await Sandbox.create({ runtime: 'node24', persistent: false });
const install = await sandbox.runCommand('bash', [
'-c',
'curl -fsSL https://johnvansickle.com/ffmpeg/releases/ffmpeg-7.0.2-amd64-static.tar.xz | tar -xJ --strip-components=1',
]);
if (install.exitCode !== 0) {
throw new Error(`FFmpeg install failed: ${await install.stderr()}`);
}
const snapshot = await sandbox.snapshot();
console.log('SANDBOX_SNAPSHOT_ID=' + snapshot.snapshotId);
// snapshot() stops the sandbox automatically — no stop() needed
}
main().catch(console.error);

Run the script and save the printed ID:

Terminal
pnpm tsx scripts/build-snapshot.ts
.env.local
SANDBOX_SNAPSHOT_ID=your_snapshot_id_here

Add the SANDBOX_SNAPSHOT_ID line to your .env.local file, and add the same variable to your project's environment variables in the Vercel dashboard so production deployments can use it.

Copy link to heading4. Create the processing route

This route runs the whole sandbox lifecycle: create from the snapshot, download, process, read back, and stop. Create the file and start with the sandbox setup:

app/api/process/route.ts
import { put } from '@vercel/blob';
import { Sandbox } from '@vercel/sandbox';
import { NextResponse } from 'next/server';
// Allow the route to wait for the sandbox. Must exceed the sandbox timeout
// below, and requires a Pro or Enterprise plan for values above 300.
export const maxDuration = 720;
export async function POST(request: Request): Promise<NextResponse> {
const { url } = await request.json();
let hostname: string;
try {
hostname = new URL(url).hostname;
} catch {
return NextResponse.json({ error: 'Invalid blob URL' }, { status: 400 });
}
if (!hostname.endsWith('.public.blob.vercel-storage.com')) {
return NextResponse.json({ error: 'Invalid blob URL' }, { status: 400 });
}
const sandbox = await Sandbox.create({
source: {
type: 'snapshot',
snapshotId: process.env.SANDBOX_SNAPSHOT_ID!,
},
persistent: false, // one-off job, no snapshot needed on stop
timeout: 10 * 60 * 1000, // 10 minutes
resources: { vcpus: 4 },
});
try {
// Steps 5-7 go here
} finally {
await sandbox.stop();
}
}

The sandbox boots from your snapshot with FFmpeg already at /vercel/sandbox/ffmpeg, so the route needs no install step. The persistent: false option skips the automatic filesystem snapshot on stop, since each processing job is independent. Validating the URL's hostname against your Blob store prevents the route from downloading arbitrary URLs into your sandbox.

Two separate timeouts govern this route, and they need to agree. The sandbox timeout limits how long the microVM runs (default 5 minutes). The function's maxDuration limits how long the route can hold the request open (default 300 seconds with fluid compute). Set maxDuration higher than the sandbox timeout, otherwise the request fails with a 504 while the sandbox keeps running. Waiting on the sandbox counts as I/O, so Active CPU billing pauses during the wait and only the wall-clock limit applies. On the Hobby plan, maxDuration caps at 300 seconds, so reduce the sandbox timeout to 4 minutes to stay within it.

Copy link to heading5. Download the uploaded file into the sandbox

Sandboxes have outbound network access, so the sandbox can fetch the public blob URL directly. Downloading inside the sandbox keeps the file off your function entirely:

const download = await sandbox.runCommand('curl', [
'-fsSL',
'-o',
'input.mp4',
url,
]);
if (download.exitCode !== 0) {
throw new Error(`Download failed: ${await download.stderr()}`);
}

Commands run in /vercel/sandbox by default, so relative paths like input.mp4 resolve there.

Copy link to heading6. Run FFmpeg

Extract a thumbnail from the first second of the video:

const extract = await sandbox.runCommand('./ffmpeg', [
'-i',
'input.mp4',
'-ss',
'00:00:01.000',
'-frames:v',
'1',
'thumbnail.jpg',
]);
if (extract.exitCode !== 0) {
throw new Error(`FFmpeg failed: ${await extract.stderr()}`);
}

A single-frame extraction completes in seconds, making it a good first command for verifying the pipeline. Once this works, you can swap in longer operations like transcoding (./ffmpeg -i input.mp4 -c:v libx264 output.mp4) without changing anything else in the route.

Copy link to heading7. Store the result in Vercel Blob

Read the thumbnail out of the sandbox and upload it to Blob:

const thumbnail = await sandbox.readFileToBuffer({ path: 'thumbnail.jpg' });
if (!thumbnail) {
throw new Error('Thumbnail was not created');
}
const blob = await put('thumbnails/thumbnail.jpg', thumbnail, {
access: 'public',
addRandomSuffix: true,
contentType: 'image/jpeg',
});
return NextResponse.json({ thumbnailUrl: blob.url });

The readFileToBuffer() method resolves to null when the file doesn't exist, so the check catches cases where FFmpeg produced no output. The finally block from step 4 stops the sandbox whether the job succeeds or fails, so you're never billed for an idle microVM.

Copy link to heading8. Test the pipeline

Run your development server and upload a short video:

Terminal
pnpm dev

Visit /upload, select an MP4 file, and select Upload and process. After the upload and processing complete, the page displays the generated thumbnail served from your Blob store. You can verify both blobs in your project's Storage tab in the Vercel dashboard.

The onUploadCompleted callback from step one does not fire in local development because Vercel Blob cannot reach localhost. The upload itself still succeeds. To test the callback locally, run a tunnel such as ngrok and set VERCEL_BLOB_CALLBACK_URL in .env.local.

Copy link to headingBest practices

Copy link to headingKeep the snapshot fresh

Snapshots expire 30 days after their last use by default. Regular traffic keeps the processing snapshot alive, but a low-traffic project may need to rerun the setup script and update SANDBOX_SNAPSHOT_ID. Rebuilding the snapshot is also how you pick up new FFmpeg releases, so consider rerunning the script as part of your regular maintenance.

Copy link to headingMove long jobs out of the request cycle

The processing route holds the HTTP request open while the sandbox works. That's fine for thumbnails, but a long transcode can exceed your function's maximum duration. For heavier workloads, return early from the route and run the sandbox job in the background, for example with a queue or Vercel Workflow, then notify the client when the output blob is ready.

Copy link to headingRestrict what enters the sandbox

Validate that submitted URLs point at your own Blob store, as shown in step 4, and keep allowedContentTypes narrow in the upload token route. The sandbox contains the damage a malicious file can cause, but rejecting bad input early saves compute resources.

Copy link to headingSize the sandbox to the workload

FFmpeg is CPU-bound, and sandbox billing is based on Active CPU time, provisioned memory, and network transfer. Four vCPUs speed up transcoding through parallelism, but a single-frame thumbnail extraction runs fine on the default 2 vCPUs. Start small and increase resources.vcpus only if jobs are too slow.

When testing larger uploads or transcoding jobs, inspect Memory Usage alongside CPU usage. Compare memory usage with the sandbox’s configured limit and 85% warning line, then use project-level average, P75, and P95 views to understand usage across sandboxes.

Copy link to headingTroubleshooting

Copy link to headingUploads fail with a token error

The handleUpload helper requires the BLOB_READ_WRITE_TOKEN environment variable to generate client tokens. Confirm your Blob store is connected to the project and run vercel env pull again to refresh your local environment.

Copy link to headingThe sandbox can't be created locally

The Sandbox SDK authenticates with a Vercel OIDC token. Locally, this token comes from vercel env pull and expires periodically, so pull again if creation fails with an authentication error. In production on Vercel, authentication is automatic.

Copy link to headingSandbox creation fails with a snapshot error

Snapshots expire 30 days after their last use, and an expired or deleted snapshot can no longer be used as a sandbox source. Rerun scripts/build-snapshot.ts to create a new snapshot, then update SANDBOX_SNAPSHOT_ID in .env.local and in your project's environment variables on Vercel.

Copy link to headingProcessing times out

Check which limit expired. A 504 response with FUNCTION_INVOCATION_TIMEOUT means the function's maxDuration ran out; increase it in the route file (Pro and Enterprise plans support up to 800 seconds, with higher values in beta). If the sandbox itself stopped mid-job, increase the timeout option in Sandbox.create() or extend a running sandbox with sandbox.extendTimeout(). Keep maxDuration above the sandbox timeout so the function outlives the job. For work that outgrows both limits, move the job to the background as described in the best practices.

Related documentation

More Vercel Blob guides