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
What Vercel builds and functions report

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();
200 with the out.mp4 link when it finishes inside 280 s, 202 and a webhook when it doesn't

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

Vercel Functions next to reelsdk
LimitVercel Functionsreelsdk
Request and response body4.5 MB, then 413 FUNCTION_PAYLOAD_TOO_LARGETakes URLs, inputs up to 500 MB each and 1 GB per job
Bundle size250 MB uncompressed, 5 GB with large functionsNothing to bundle, ffmpeg 8.1 is on the render machine
Duration300 s on Hobby, up to 800 s on ProUp to 10 min per job, ?wait= holds up to 300 s
Memory and CPU2 GB and 1 vCPU, up to 4 GB and 2 vCPU on ProA separate machine for every job
Getting files inBrowser to Vercel Blob with a client uploadBlob 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.

  1. Add the key and the webhook secret

    Create a key in the reelsdk dashboard. vercel env add asks 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 --prod
    
  2. Upload 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-upload only hands out a short-lived token through handleUpload. That needs BLOB_READ_WRITE_TOKEN, which Vercel adds to the project when you create a Blob store. onUploadCompleted is 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>
      );
    }
    
  3. Send the URL and your ffmpeg arguments

    The route handler sets maxDuration = 300 and 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: inputs names the files ffmpeg reads and outputs lists 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 },
      );
    }
    
  4. Verify the webhook

    Renders that outlast the wait arrive here. Read the body with request.text() before parsing, check Reel-Signature with createHmac and timingSafeEqual, and reject timestamps more than 300 s old. VERCEL_PROJECT_PRODUCTION_URL holds your production domain without https://, 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.

All integrations

Ready to build?

Ship video from your app today

Your first $1 of renders is free, and one key covers every endpoint.

Moonlit lake with a lit cabin under the mountains