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

**Author:** Ben Sabic

---

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](https://vercel.com/sandbox) runs each job in an isolated Firecracker microVM, where you can install any binary, while [Vercel Blob](https://vercel.com/storage/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.

## Overview

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
  

## Prerequisites

Before you begin, make sure you have:

- Node.js 22+ and a package manager (e.g., [pnpm](https://pnpm.io/))
  
- A [Vercel account](https://vercel.com/signup)
  
- The `@vercel/blob` and `@vercel/sandbox` packages installed:
  

This guide uses [Next.js App Router](https://nextjs.org/docs/app), but both SDKs work with virtually any framework.

## How 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.

## Steps

### 1\. 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:

`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. ### 2\. Build the upload page Create a page that uploads the file to Blob, then sends the resulting URL to your processing route: `'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. ### 3\. Build a snapshot with FFmpeg installed Sandboxes run on Amazon Linux 2023, which doesn't ship FFmpeg in its default package repositories. 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: `pnpm i -D tsx dotenv` ``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: `pnpm tsx scripts/build-snapshot.ts` `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](https://vercel.com/d?to=%2F%5Bteam%5D%2F%5Bproject%5D%2Fsettings%2Fenvironment-variables) in the Vercel dashboard so production deployments can use it.

### 4\. 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:

`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.

### 5\. 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. ### 6\. 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. ### 7\. 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. ### 8\. Test the pipeline Run your development server and upload a short video: `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`. ## Best practices ### Keep 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. ### Move 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. ### Restrict 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. ### Size 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. ## Troubleshooting ### Uploads 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. ### The 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. ### Sandbox 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. ### Processing 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 resources and Next steps - Learn more about [client uploads with Vercel Blob](https://vercel.com/docs/vercel-blob/client-upload)
  
- Explore the [Vercel Sandbox SDK reference](https://vercel.com/docs/sandbox/sdk-reference)
  
- Go deeper on [sandbox snapshots](https://vercel.com/kb/guide/how-to-use-snapshots-for-faster-sandbox-startup), including measuring the startup speedup
  
- See a production-scale version of this pattern in the [HyperFrames template](https://vercel.com/templates/ai/hyperframes-on-vercel), which renders MP4s in Vercel Sandbox and stores them in Vercel Blob

---

[View full KB sitemap](/kb/sitemap.md)
