Disclosure: I work on Revana, a pay-as-you-go API for image and video models. The code uses its SDK (npm install revana, MIT) and REST API, but the structure applies to any video API you put behind a web app.

Putting a "Generate video" button in a Next.js app sounds like one fetch. In practice video generation has three properties that shape the whole design:

  • It's slow. A 5-second clip takes one to several minutes. A route handler that waits for it will hit your platform's function time limit, and a user who refreshes loses the job.
  • It costs real money per request. Anyone who finds your API route can call it in a loop. The key must stay on the server, and the server must decide what one request may cost.
  • It fails sometimes, and a naive retry starts a second paid job.

So the shape is: a route that starts a job and returns its id, a route that reports status, a route that serves the file, and a client that polls (or a webhook that tells you when it's done). This post builds those with the App Router.

Setup

npm install revana 
# .env.local REVANA_API_KEY=sr_live_... # from revana.si/dashboard; server only, no NEXT_PUBLIC_ prefix REVANA_MAX_COST_USD=2 # the most one request may cost 

1. Start a job: app/api/videos/route.ts

import Revana, { RevanaError } from "revana"; const revana = new Revana(); // reads REVANA_API_KEY const MAX_USD = Number(process.env.REVANA_MAX_COST_USD ?? 2); // Only what your product offers. Never pass a client-chosen model or input straight through. const MODELS: Record<string, { resolution: string; durations: number[] }> = { "wan-3.0": { resolution: "720p", durations: [5, 10] }, "kling-4.0": { resolution: "720p", durations: [5, 10] }, "seedance-2.5": { resolution: "720p", durations: [5] }, }; export async function POST(req: Request) { const user = await requireUser(req); // your auth: session check, plus a per-user rate limit const { model, prompt, duration, imageUrl } = await req.json(); const spec = MODELS[model]; if (!spec || !spec.durations.includes(duration) || typeof prompt !== "string" || prompt.length > 2000) { return Response.json({ error: "invalid request" }, { status: 400 }); } const body = { model, input: { prompt, duration, resolution: spec.resolution, aspect_ratio: "9:16", audio: true, ...(imageUrl ? { image_url: imageUrl } : {}), }, }; // 1. Price the exact request (free: nothing is created or charged). const q = await fetch("https://api.revana.si/v1/generations/quote", { method: "POST", headers: { authorization: `Bearer ${process.env.REVANA_API_KEY}`, "content-type": "application/json" }, body: JSON.stringify(body), }); const quote = await q.json(); if (!q.ok) return Response.json({ error: quote.error?.message ?? "cannot price this" }, { status: 422 }); if (quote.quoted_usd > MAX_USD) { return Response.json({ error: `costs $${quote.quoted_usd}, over the $${MAX_USD} limit` }, { status: 402 }); } // 2. Create it, capped at the quote, with the client's idempotency key. const key = req.headers.get("idempotency-key"); if (!key || key.length > 100) return Response.json({ error: "Idempotency-Key header required" }, { status: 400 }); try { const gen = await revana.generations.create( { ...body, max_cost_usd: quote.quoted_usd }, { idempotencyKey: `${user.id}:${key}` }, ); await saveJob({ id: gen.id, userId: user.id, quotedUsd: quote.quoted_usd }); // your DB return Response.json({ id: gen.id, status: gen.status, quoted_usd: gen.quoted_usd }, { status: 202 }); } catch (e) { if (e instanceof RevanaError) return Response.json({ error: e.code, message: e.message }, { status: e.status || 502 }); throw e; } } 

What each part protects against:

  • Allow-list, not pass-through. The client picks from your models and durations; the server fills in resolution and everything else. Otherwise someone sends resolution: "1080p", duration: 30 and you pay for it.
  • Quote, then cap at the quote. POST /v1/generations/quote takes the same body as the create and answers quoted_usd (the normal price) and reserved_usd (the most it could cost if a dearer backup provider delivers). Some inputs cost more than a model's headline rate: on November 5, 2026, a 5-second 720p Seedance 2.5 clip quoted $1.05 from text but $1.25 with a start image. The quote handles that, and max_cost_usd: quoted_usd makes it a hard ceiling. A request that can't run at that price fails with max_cost_exceeded and costs nothing.
  • The idempotency key comes from the client. The browser generates one key per click (below) and resends it if the request times out. The same key with the same body returns the original job instead of a second paid one. Prefixing it with the user id stops two users' keys from colliding, since keys are scoped to your Revana account, not to your users.
  • Save the job with its owner. The status and file routes below check that the job belongs to the caller. Without that, anyone who guesses or sees a gen_... id can read someone else's video through your key.

requireUser and saveJob stand in for your own auth and database.

2. Report status: app/api/videos/[id]/route.ts

import Revana from "revana"; const revana = new Revana(); export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { const user = await requireUser(req); const { id } = await params; const job = await findJob(id); if (!job || job.userId !== user.id) return Response.json({ error: "not found" }, { status: 404 }); const g = await revana.generations.get(id); return Response.json({ id: g.id, status: g.status, // queued | running | succeeded | failed cost_usd: g.cost_usd, // null while running, 0 if it failed error: g.error && { code: g.error.code, message: g.error.message, retryable: g.error.retryable }, }); } 

The browser gets only what it needs. error.message is written to be shown to the person who asked, and retryable says whether trying the same request later can work (model_busy, timeout) or whether it needs to change (content_policy, invalid_input). A failed generation is charged $0.

3. Serve the file: app/api/videos/[id]/content/route.ts

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { const user = await requireUser(req); const { id } = await params; const job = await findJob(id); if (!job || job.userId !== user.id) return new Response("not found", { status: 404 }); const res = await fetch(`https://api.revana.si/v1/generations/${encodeURIComponent(id)}/content`, { headers: { authorization: `Bearer ${process.env.REVANA_API_KEY}` }, redirect: "manual", cache: "no-store", }); const location = res.headers.get("location"); if (location) return Response.redirect(location, 302); // a short-lived link to the file return new Response(res.body, { status: res.status, headers: { "content-type": res.headers.get("content-type") ?? "video/mp4" } }); } 

/content answers with a redirect to a short-lived file link. Handling the redirect yourself (redirect: "manual") means your Authorization header is never sent to the storage host, and the browser follows the link without ever seeing your key. Result files expire, so if users should keep their videos, copy the file into your own storage (S3, R2, Vercel Blob) when the job succeeds rather than relying on the link later.

4. The client: start, poll, show

"use client"; import { useEffect, useRef, useState } from "react"; export function VideoButton() { const [job, setJob] = useState<{ id: string; status: string; error?: { message: string } } | null>(null); const [error, setError] = useState<string | null>(null); const key = useRef<string | null>(null); async function start() { setError(null); key.current ??= crypto.randomUUID(); // one key per intent; reused if this request is retried const res = await fetch("/api/videos", { method: "POST", headers: { "content-type": "application/json", "idempotency-key": key.current }, body: JSON.stringify({ model: "wan-3.0", prompt: "a paper boat drifting down a rainy street at night", duration: 5 }), }); const data = await res.json(); if (!res.ok) return setError(data.message ?? data.error); key.current = null; // the next click is a new video setJob(data); } useEffect(() => { if (!job || job.status === "succeeded" || job.status === "failed") return; const t = setTimeout(async () => setJob(await fetch(`/api/videos/${job.id}`).then((r) => r.json())), 5000); return () => clearTimeout(t); }, [job]); return ( <div> <button onClick={start} disabled={!!job && !["succeeded", "failed"].includes(job.status)}>Generate video</button> {error && <p>{error}</p>} {job && job.status !== "succeeded" && job.status !== "failed" && <p>Rendering… ({job.status})</p>} {job?.status === "failed" && <p>{job.error?.message} You were not charged.</p>} {job?.status === "succeeded" && <video src={`/api/videos/${job.id}/content`} controls autoPlay muted playsInline />} </div> ); } 

Polling every 5 seconds is plenty for a job that takes minutes, and it stays well clear of the API's per-second burst guard. Because the job id is in your database, you can also list a user's past and in-progress videos after a refresh instead of keeping state only in React.

5. Optional: a webhook instead of polling the API

If your app is deployed on public HTTPS, Revana's queue endpoint can call you when a job finishes. It takes the model input directly (no {model, input} wrapper), with max_cost_usd alongside it, and the callback URL in fal_webhook:

const callback = encodeURIComponent("https://your-app.example/api/revana/webhook"); const res = await fetch(`https://api.revana.si/v1/queue/revana/${model}?fal_webhook=${callback}`, { method: "POST", headers: { authorization: `Bearer ${process.env.REVANA_API_KEY}`, "content-type": "application/json", "idempotency-key": `${user.id}:${key}`, }, body: JSON.stringify({ ...body.input, max_cost_usd: quote.quoted_usd }), }); const { request_id } = await res.json(); // 202; store it with the user and the model 

The handler, app/api/revana/webhook/route.ts:

export async function POST(req: Request) { const { request_id } = await req.json(); const job = await findJobByRequestId(request_id); if (!job || job.done) return new Response(null, { status: 200 }); // unknown or already handled // Callbacks are unsigned: confirm with your own key before trusting them. const s = await fetch( `https://api.revana.si/v1/queue/revana/${job.model}/requests/${encodeURIComponent(request_id)}/status`, { headers: { authorization: `Bearer ${process.env.REVANA_API_KEY}` }, cache: "no-store" }, ).then((r) => r.json()); if (s.status !== "COMPLETED") return new Response(null, { status: 200 }); await markJobDone(request_id, { failed: Boolean(s.error), error: s.error }); // then notify the user return new Response(null, { status: 200 }); } 

The rules from the queue docs that this follows:

  • Unsigned callbacks. Treat the POST as "something may have changed" and check the status with your key.
  • At-least-once delivery. The same request_id can arrive more than once, so the handler deduplicates on it. Answer 2xx quickly; failed deliveries are retried for up to 24 hours.
  • Public HTTPS on port 443, no redirects. A localhost dev server can't receive it, so keep the polling path for development.

A finished queue job's file is at GET /v1/queue/revana/{model}/requests/{request_id}/content, with your key, and the same manual-redirect handling as above.

Costs, for planning

Quotes for a 5-second, 720p, 9:16 clip with audio from text, on November 5, 2026: Wan 3.0 $0.35, Kling 4.0 $0.68, Seedance 2.5 $1.05, and Veo 3.1 Fast $0.60 for 6 seconds (it takes 4, 6 ...