Vercel + reelsdk
Run ffmpeg from a Vercel route handler, without bundling it
Vercel Functions cap request and response bodies at 4.5 MB and function bundles at 250 MB uncompressed, and Next.js file tracing can leave the ffmpeg binary out of the bundle entirely. reelsdk runs your ffmpeg 8.1 arguments on its own machines: upload the source to Vercel Blob, send the URL from a route handler and get an mp4 link back, for $0.01 per compute minute with the first $1 free.
- The same ffmpeg arguments, no binary in your bundle
- Inputs up to 500 MB each, uploaded straight to Vercel Blob
- A 280 s wait fits inside a 300 s maxDuration
Serverless Function has exceeded the unzipped maximum size of 250 MB
413: FUNCTION_PAYLOAD_TOO_LARGE
Sources: Vercel 250 MB troubleshooting guide, Vercel FUNCTION_PAYLOAD_TOO_LARGE
The same job as one call to reelsdk
const REELSDK = "https://api.reelsdk.com";
const res = await fetch(`${REELSDK}/v1/ffmpeg?wait=280`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.REELSDK_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
inputs: { "in.mp4": blobUrl },
args: [
"-i", "in.mp4",
"-vf", "crop=ih*9/16:ih,scale=1080:1920,setsar=1",
"out.mp4",
],
outputs: ["out.mp4"],
}),
});
const { job } = await res.json();
Why ffmpeg breaks here
4.5 MB in, 4.5 MB out, 250 MB to ship
A Vercel Function accepts and returns at most 4.5 MB, so posting a raw video to a route handler fails with 413: FUNCTION_PAYLOAD_TOO_LARGE, and so does sending the mp4 back. Shipping ffmpeg inside the function has its own traps: Next.js file tracing can leave the ffmpeg-static binary out, which shows up as spawn ffmpeg ENOENT, and forcing it in with outputFileTracingIncludes can push the bundle past the 250 MB uncompressed limit.
New projects get large functions of up to 5 GB by default, and existing ones can opt in with VERCEL_SUPPORT_LARGE_FUNCTIONS=1. If ffmpeg already works in your function on small files, keep it. The body limit doesn't move, though, and a CPU-bound encode spends your function's duration and its 2 GB and 1 vCPU (4 GB and 2 vCPU at most on Pro) while it runs. Move the encode out when inputs get big, renders get long or you need captions.
Vercel Functions next to reelsdk
| Limit | Vercel Functions | reelsdk |
|---|---|---|
| Request and response body | 4.5 MB, then 413 FUNCTION_PAYLOAD_TOO_LARGE | Takes URLs, inputs up to 500 MB each and 1 GB per job |
| Bundle size | 250 MB uncompressed, 5 GB with large functions | Nothing to bundle, ffmpeg 8.1 is on the render machine |
| Duration | 300 s on Hobby, up to 800 s on Pro | Up to 10 min per job, ?wait= holds up to 300 s |
| Memory and CPU | 2 GB and 1 vCPU, up to 4 GB and 2 vCPU on Pro | A separate machine for every job |
| Getting files in | Browser to Vercel Blob with a client upload | Blob URLs as inputs, or POST /v1/uploads up to 100 MB |
Vercel Functions limits from Vercel Functions limits, checked October 2026.
Set it up
A Blob upload, a route handler and a webhook
You need a reelsdk key and its webhook secret. The browser uploads the source video to Vercel Blob, a route handler sends the URL and your ffmpeg arguments to reelsdk, and a webhook route catches renders that run longer than the wait.
Add the key and the webhook secret
Create a key in the reelsdk dashboard.
vercel env addasks for each value, then a production deploy picks them up. Add them for Preview too if you test there.terminal vercel env add REELSDK_KEY production vercel env add REELSDK_WEBHOOK_SECRET production vercel deploy --prodUpload the source straight to Vercel Blob
The 4.5 MB limit applies to your functions, not to Blob. With a client upload the browser sends the file directly to Vercel Blob, and
/api/blob-uploadonly hands out a short-lived token throughhandleUpload. That needsBLOB_READ_WRITE_TOKEN, which Vercel adds to the project when you create a Blob store.onUploadCompletedis called by Vercel, so locally it only fires behind a tunnel such as ngrok.app/api/blob-upload/route.ts import { handleUpload, type HandleUploadBody } from "@vercel/blob/client"; export async function POST(request: Request) { const body = (await request.json()) as HandleUploadBody; try { const json = await handleUpload({ body, request, onBeforeGenerateToken: async () => { // We check the signed-in user here before handing out an upload token return { allowedContentTypes: ["video/mp4", "video/quicktime", "video/webm"], addRandomSuffix: true, }; }, onUploadCompleted: async ({ blob }) => { console.log("upload finished", blob.url); }, }); return Response.json(json); } catch (error) { return Response.json({ error: (error as Error).message }, { status: 400 }); } }app/reframe-form.tsx "use client"; import { upload } from "@vercel/blob/client"; import { useState } from "react"; export function ReframeForm() { const [status, setStatus] = useState(""); async function onChange(event: React.ChangeEvent<HTMLInputElement>) { const file = event.target.files?.[0]; if (!file) return; setStatus("Uploading"); const blob = await upload(file.name, file, { access: "public", handleUploadUrl: "/api/blob-upload", }); setStatus("Rendering"); const res = await fetch("/api/reframe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ videoUrl: blob.url }), }); const result = await res.json(); setStatus(result.video ?? `Job ${result.id} is ${result.status}`); } return ( <label> <input type="file" accept="video/*" onChange={onChange} /> <span>{status}</span> </label> ); }Send the URL and your ffmpeg arguments
The route handler sets
maxDuration = 300and asks reelsdk to wait up to 280 s, so most renders come back in the same response with the mp4 link. This one crops a landscape video to a 1080x1920 vertical. reelsdk runs the arguments without a shell:inputsnames the files ffmpeg reads andoutputslists the files to keep.app/api/reframe/route.ts export const maxDuration = 300; const REELSDK = "https://api.reelsdk.com"; export async function POST(request: Request) { const { videoUrl } = await request.json(); const res = await fetch(`${REELSDK}/v1/ffmpeg?wait=280`, { method: "POST", headers: { Authorization: `Bearer ${process.env.REELSDK_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ inputs: { "in.mp4": videoUrl }, args: [ "-i", "in.mp4", "-vf", "crop=ih*9/16:ih,scale=1080:1920,setsar=1", "-c:v", "libx264", "-crf", "20", "-c:a", "aac", "out.mp4", ], outputs: ["out.mp4"], webhook_url: `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}/api/reelsdk-webhook`, }), }); const { job, error } = await res.json(); if (!res.ok) return Response.json({ error }, { status: res.status }); // 200 means the mp4 is ready, 202 means the webhook delivers it later return Response.json( { id: job.id, status: job.status, video: job.outputs?.["out.mp4"] ?? null }, { status: res.status }, ); }Verify the webhook
Renders that outlast the wait arrive here. Read the body with
request.text()before parsing, checkReel-SignaturewithcreateHmacandtimingSafeEqual, and reject timestamps more than 300 s old.VERCEL_PROJECT_PRODUCTION_URLholds your production domain withouthttps://, which is why the route handler adds it.app/api/reelsdk-webhook/route.ts import { createHmac, timingSafeEqual } from "node:crypto"; export async function POST(request: Request) { const body = await request.text(); const signature = request.headers.get("reel-signature") ?? ""; if (!verifyReelSignature(signature, body, process.env.REELSDK_WEBHOOK_SECRET!)) { return new Response("invalid signature", { status: 401 }); } const job = JSON.parse(body); // We save job.status and job.outputs["out.mp4"] for job.id in the database here console.log("reelsdk job finished", job.id, job.status); return new Response("ok"); } function verifyReelSignature(header: string, body: string, secret: string) { const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2))); const age = Math.abs(Date.now() / 1000 - Number(parts.t)); if (!/^[0-9a-f]{64}$/i.test(parts.v1 ?? "") || !(age <= 300)) return false; const expected = createHmac("sha256", secret).update(`${parts.t}.${body}`).digest(); return timingSafeEqual(expected, Buffer.from(parts.v1, "hex")); }
FAQ
Video on Vercel Functions, answered
Why does ffmpeg fail with spawn ffmpeg ENOENT or the 250 MB error on Vercel?
Next.js file tracing can miss the ffmpeg-static binary unless you add it with outputFileTracingIncludes, and then the function throws spawn ffmpeg ENOENT at runtime. Including it can push the bundle past the 250 MB uncompressed limit, unless the project uses large functions of up to 5 GB. Calling POST /v1/ffmpeg means there is no binary to bundle.
How do I get around 413 FUNCTION_PAYLOAD_TOO_LARGE for video on Vercel?
The 4.5 MB request and response body limit for Vercel Functions can't be raised. Upload from the browser straight to Vercel Blob with a client upload, or POST raw files up to 100 MB to reelsdk's /v1/uploads. Then pass the URL to reelsdk, which takes inputs up to 500 MB each and 1 GB per job.
Will a render fit inside my function's maxDuration?
Usually. A render machine is ready in about 1.9 s at the median, captions on a 30 s clip take 30 to 43 s and a 30 s slideshow with voiceover takes 40 to 45 s. With maxDuration = 300 and ?wait=280 most jobs come back in the same response. Hobby functions stop at 300 s, Pro allows up to 800 s.
What happens when a render takes longer than the wait?
reelsdk answers with a 202 and the job id, and the job keeps running for up to 10 minutes. Pass a webhook_url and reelsdk POSTs the finished job with a Reel-Signature header, with up to 3 delivery attempts. Or long-poll GET /v1/jobs/{id}?wait=N from another request.
What does it cost?
Raw ffmpeg 8.1 is $0.01 per compute minute. Slideshows and burned-in captions are $0.02 per output minute, and faceless shorts are $0.25 up to 60 s or $0.35 up to 90 s. There is no subscription, the first $1 is free, and each key runs 3 jobs at once and up to 500 a day.
The same fix on other platforms
All integrationsReady to build?
Ship video from your app today
Your first $1 of renders is free, and one key covers every endpoint.
