cd /news/ai-products/add-ai-video-generation-to-a-next-js… · home › topics › ai-products › article
[ARTICLE · art-164502] src=dev.to ↗ pub= topic=ai-products verified=true sentiment=· neutral

Add AI video generation to a Next.js app: route handlers, spend caps, polling and webhooks

A developer working on the Revana image and video generation API published a guide for integrating AI video generation into a Next.js App Router application, using separate route handlers to start jobs, report status, and serve generated files. The design adds a pre-flight quote endpoint that prices each request before creation, a hard max_cost_usd ceiling, idempotency keys, and server-side model allowlisting to prevent clients from selecting expensive models or durations. The author notes that a 5-second 720p Seedance 2.5 clip quoted $1.05 from text but $1.25 with a start image on November 5, 2026.

by read8 min views2 publishedNov 5, 2026

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:

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.

npm install revana
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

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:

resolution: "1080p", duration: 30 and you pay for it.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.gen_... id can read someone else's video through your key. requireUser and saveJob stand in for your own auth and database.

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.

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.

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

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:

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

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 or 8). Re-quote in code rather than hard-coding these. Prices for every model are at revana.si/pricing, and GET https://api.revana.si/v1/models lists each model's options and price without a key.

If you want to try the whole flow before spending anything, minimax-h3-free is a free model in the same catalog (5 to 15 s, sound always on). Its quote comes back $0.00. The account needs one top-up first (the smallest pack is $10), but free jobs never spend it.

max_cost_usd at the quote (or at The SDK source and more examples: revana-js, and revana-skills/examples/nextjs for a minimal version of these routes. API reference: revana.si/docs.

Quotes in this post are from Revana's live API on November 5, 2026.

── more in #ai-products 4 stories · sorted by recency
── more on @revana 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/add-ai-video-generat…] indexed:0 read:8min 2026-11-05 · —