Next.js Plus fal: A Pattern That Scales
Server actions, edge routes, client-side submissions. The split that keeps your bundle small and your API key safe.
The split that keeps your bundle small and your key safe
Code runs in three places in a modern Next.js app: browser, edge, Node server. Your fal key is allowed in exactly one: the Node server. That means route handlers on the Node runtime, or server actions.
Edge works for many calls, but its fetch surface and streaming semantics are a separate profile. Pick Node for fal calls unless you have a strong reason not to.
The shape

- Browser: a form, a preview, a gallery. No fal import. No key.
- Server action or route handler: reads the key from env, calls fal, persists the request id.
- fal.ai: runs the model.
The browser never imports @fal-ai/client. Treat the package as server only.
A server action for short jobs
For anything under three minutes with a spinner, subscribe inside a server action is the shortest path.
1// app/actions/render.ts2"use server";34import { fal } from "@fal-ai/client";5import { cookies } from "next/headers";67fal.config({ credentials: process.env.FAL_KEY });89export async function renderShort(prompt: string) {10 const userId = (await cookies()).get("uid")?.value ?? "anon";1112 const result = await fal.subscribe("fal-ai/pixverse/v6/text-to-video", {13 input: { prompt, duration: 5, resolution: "1080p" },14 logs: true,15 });1617 await db.insert("generations", {18 user_id: userId,19 request_id: result.requestId,20 endpoint: "fal-ai/pixverse/v6/text-to-video",21 cost_cents: Math.round(5 * 3), // Pixverse v6 is $0.03-$0.12/sec (tiered); 5s at 360p no audio = $0.1522 video_url: result.data.video.url,23 });2425 return { url: result.data.video.url, requestId: result.requestId };26}
Pixverse v6 at $0.03-$0.12/sec (tiered) is the right model for iterative UIs. Users click many buttons.
A route handler for long jobs
For anything over three minutes (Veo 3.1 at 4K, Kling v3 Pro multi prompt), do not hold the HTTP connection. Submit, return the request id, let a webhook finish.
1// app/api/render/route.ts2import { fal } from "@fal-ai/client";34export const runtime = "nodejs";56export async function POST(req: Request) {7 fal.config({ credentials: process.env.FAL_KEY });8 const { prompt } = await req.json();910 const { request_id } = await fal.queue.submit("fal-ai/veo3.1", {11 input: { prompt, duration: "8s", resolution: "4k", aspect_ratio: "16:9" },12 webhookUrl: `${process.env.APP_URL}/api/fal/webhook`,13 });1415 await db.insert("generations", { request_id, status: "IN_QUEUE" });16 return Response.json({ requestId: request_id });17}
An 8 second Veo 3.1 4K clip is $3.20. You do not want that on a serverless function that might time out.
The button, client side

1"use client";23import { useTransition, useState } from "react";4import { renderShort } from "./actions/render";56export default function Page() {7 const [isPending, start] = useTransition();8 const [url, setUrl] = useState<string | null>(null);910 return (11 <form action={(fd) => start(async () => {12 const r = await renderShort(String(fd.get("prompt")));13 setUrl(r.url);14 })}>15 <input name="prompt" />16 <button disabled={isPending}>{isPending ? "rendering..." : "go"}</button>17 {url && <video src={url} controls />}18 </form>19 );20}
No key, no fal import, no endpoint string in the bundle.
Edge runtime: only if you have a reason
Edge gives you lower cold start. It also constrains you: stricter fetch, different timeout profile. For fal.queue.submit (one POST) edge is fine. For fal.subscribe on a long job, edge will time out earlier than Node. Default to Node, flip to edge only for short POSTs.
Environment and secrets
1FAL_KEY=sk-...2APP_URL=https://your-app.vercel.app3FAL_WEBHOOK_SECRET=whsec_...
Vercel's env UI, never commit. Add to production and preview. Skip development unless you actually call real endpoints from local.
What not to do
Do not import @fal-ai/client in a client component. Do not pass the key to the browser. Do not run subscribe on a function with a 10 second max duration. Do not skip writing the request id at submit time.
Get this split right once and the rest of the app stops having bundle and secrets questions.