Deploying to Vercel
How to deploy a flow-state-dev Next.js application to Vercel. The @flow-state-dev/vercel package handles SSE headers, heartbeats, and runtime configuration so you don't have to.
If you haven't set up a Next.js project with the framework yet, start with the Next.js Setup guide first.
Prerequisites
- A Next.js 14+ project with flow-state-dev integrated (setup guide)
- A Vercel account
- The Vercel CLI (
npm i -g vercel) — optional but useful for testing - At least one LLM provider API key (e.g.,
OPENAI_API_KEY)
1. Install the adapter
pnpm add @flow-state-dev/vercel
2. Configure the API route
Mount the flowstate handle (from step 4) with createVercelNextHandler. One catch-all route file is enough.
import { flowstate } from "@/lib/flowstate";
import { createVercelNextHandler } from "@flow-state-dev/vercel/next";
export const { GET, POST, PATCH, DELETE } = createVercelNextHandler(flowstate);
// Next.js reads these statically — must be literal declarations, not re-exports.
export const runtime = "nodejs";
export const maxDuration = 300;
export const dynamic = "force-dynamic";
createVercelNextHandler takes care of:
- Unwrapping Next.js 15's async params
- Adding SSE headers (
Cache-Control: no-cache, no-transform,X-Accel-Buffering: no) to prevent Vercel's edge layer from buffering tokens - Injecting periodic heartbeat comments to keep long-lived connections alive
Pass the flowstate handle, not flowstate.getRouter(). The handler resolves the router lazily on the first request, which is how async store init works with no top-level await. @flow-state-dev/vercel/next requires Next.js 15+.
Next.js [...path] catch-all requires at least one segment, so add a sibling route for the bare /api/flows path (the flow listing endpoint). Use createVercelBareHandler from @flow-state-dev/vercel, passing the same lazy router:
import { flowstate } from "@/lib/flowstate";
import { createVercelBareHandler } from "@flow-state-dev/vercel";
export const { GET, POST } = createVercelBareHandler(() => flowstate.getRouter());
Route config values
Next.js reads runtime, maxDuration, and dynamic via static analysis at build time. They must be literal export const declarations in the route file — export { runtime } from '...' re-exports will not work.
| Field | Recommended value | Purpose |
|---|---|---|
runtime | "nodejs" | Vercel runtime |
maxDuration | 300 | Max function execution time in seconds |
dynamic | "force-dynamic" | Prevents Next.js from caching SSE routes |
3. Configure Next.js
If your flow-state-dev packages are local workspace dependencies (monorepo), tell Next.js to transpile them:
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: [
"@flow-state-dev/core",
"@flow-state-dev/client",
"@flow-state-dev/react",
"@flow-state-dev/server",
"@flow-state-dev/vercel",
],
};
export default nextConfig;
If you're consuming published packages from npm, you can skip transpilePackages.
4. Choose a persistence store
Vercel serverless functions run in ephemeral containers. The filesystem doesn't persist between invocations. This means:
- In-memory store: works, but every cold start loses all data
- Filesystem store: don't use it — writes succeed but data disappears on the next invocation
- SQLite store: partially works for short-lived demos (the DB file is ephemeral), but don't rely on it
For production on Vercel, use an external database like Postgres. vercelPostgresStores() from @flow-state-dev/vercel/store returns a store adapter tuned for Vercel and Neon: it bakes in the right pool options, swaps in Neon's WebSocket client for .neon.tech URLs, and skips schema init (you run migrations at build, see step 6).
Describe the runtime in lib/flowstate.ts. Use a stores profile per environment, and select with FSD_ENV (step 5):
import { after } from "next/server";
import { openai } from "@ai-sdk/openai";
import { createGateway } from "@ai-sdk/gateway";
import { createModelResolver } from "@flow-state-dev/core/models";
import { createFlowState, inMemoryStores } from "@flow-state-dev/server";
import { vercelPostgresStores } from "@flow-state-dev/vercel/store";
import myFlow from "@/flows/my-flow/flow";
// Pass explicit provider/gateway instances. The model resolver's dynamic
// require() path doesn't work in bundled Next.js — static imports do. We pass
// a pre-built resolver via `modelResolver` instead of the `models` shorthand.
const gatewayApiKey = process.env.AI_GATEWAY_API_KEY;
const modelResolver = createModelResolver({
providers: { openai },
gateways: gatewayApiKey
? { vercel: createGateway({ apiKey: gatewayApiKey }) }
: undefined,
});
export const flowstate = createFlowState({
flows: { myFlow },
modelResolver,
stores: {
prod: { primary: vercelPostgresStores() },
dev: { primary: inMemoryStores() },
},
defaultProfile: "dev",
// Disable a background Postgres query on startup that can exhaust the pool
// during serverless cold starts.
detectInterruptedOnStartup: false,
// Keep the function alive for fire-and-forget action execution.
onBackgroundWork: (promise) => after(() => promise),
});
Key points:
- Explicit provider/gateway instances: Next.js bundles server code, breaking the model resolver's dynamic
require()path. Pass a pre-builtmodelResolverwith static provider imports instead of themodelsshorthand. vercelPostgresStores(): backs theprimarycapability slot with Vercel/Neon-tuned Postgres. Connection string defaults toFSD_DB_URLthenDATABASE_URL. Schema init is skipped (run migrations at build, see step 6).storesprofiles +FSD_ENV: declare aprodand adevprofile, then setFSD_ENV=prodon Vercel to select it.NODE_ENVis not consulted, so a production build can't silently point at production infrastructure.detectInterruptedOnStartup: false: disables a background Postgres query on startup that can exhaust the pool during serverless cold starts.onBackgroundWork+after(): keeps the serverless function alive for fire-and-forget action execution. Without this, Vercel kills the function after the response is sent. It's acreateFlowStateoption, not a handler option, because the router is built insidecreateFlowState.
The @flow-state-dev/vercel/store (vercelPostgresStores) and @flow-state-dev/vercel/next (createVercelNextHandler) sub-exports are documented in the @flow-state-dev/vercel README.
5. Set environment variables
In your Vercel project settings (Settings > Environment Variables), add:
AI_GATEWAY_API_KEY=...
FSD_DB_URL=postgresql://...
FSD_ENV=prod
FSD_DB_URL is preferred over DATABASE_URL to avoid collisions with other services. The store adapter checks both (FSD_DB_URL first).
FSD_ENV selects the stores profile. Set it to prod on Vercel so vercelPostgresStores() backs the runtime. Without it, the runtime falls back to defaultProfile (dev, in-memory).
Or whichever provider keys and connection strings your flows need.
For local testing with vercel dev, use .env.local:
OPENAI_API_KEY=sk-...
6. Run schema migration as a build step
createPostgresStores initializes the schema (~30 idempotent CREATE TABLE/INDEX IF NOT EXISTS statements + an advisory-lock acquisition) every time it's called — once per cold start on Vercel. Move that work into the build instead, then pass skipSchemaInit: true at runtime (already done in step 4).
Add a one-shot migration script to your app:
import { createPostgresStores } from "@flow-state-dev/store-postgres";
const dbUrl = process.env.FSD_DB_URL ?? process.env.DATABASE_URL;
if (!dbUrl) {
console.log("[migrate] No FSD_DB_URL/DATABASE_URL set — skipping.");
process.exit(0);
}
console.log("[migrate] Initializing Postgres schema…");
const stores = await createPostgresStores({ connectionString: dbUrl });
await stores.close();
console.log("[migrate] Done.");
Wire it into your build via package.json. The script needs to run via tsx (or another bundler-aware runner) — @flow-state-dev/store-postgres uses TypeScript's bundler module resolution and emits extensionless relative imports, which raw Node ESM can't resolve:
{
"scripts": {
"vercel-build": "next build && tsx scripts/migrate.ts"
},
"devDependencies": {
"tsx": "^4.19.0"
}
}
The script exits 0 when no DB URL is set, so preview deployments without a database wired up don't fail the build. On a real DB, migration failures fail the build — better to know at deploy time than at first request.
Make FSD_DB_URL available to builds: in Vercel project settings, env vars are visibility-scoped per environment (production / preview / development) and per process (build / runtime). The migration script needs FSD_DB_URL available to build, not just runtime. Check the "Build" checkbox when adding the variable.
7. Using the bash tool on Vercel
If your flow uses @flow-state-dev/tools/bash, the bash tool needs to know how to find a sandbox runtime on Vercel. Two paths are supported.
Default: in-memory just-bash
Without any extra configuration, the kitchen-sink's selectBashProvider() falls back to just-bash on Vercel — a JavaScript reimplementation of bash with an in-memory virtual filesystem, around 70 commands, and optional Python/JS interpreters via WASM. Visitors hitting your demo can write files, read them back, and run commands in the same chat turn without any operator setup.
Limits to be aware of:
- The filesystem is in-memory and per-process. It does not survive across Vercel cold starts.
curlis allowlist-gated; arbitrary outbound HTTP is denied by default.- No real
pip install/npm installagainst the network. Use Vercel Sandbox if your demo needs that.
Upgrading to Vercel Sandbox
@vercel/sandbox provisions real Linux microVMs in iad1. To use it, configure one of the two credential paths the SDK supports:
- OIDC Federation (recommended for Vercel-hosted projects). Enable it under Project Settings → OIDC Token Generation. The SDK will fetch a per-request
x-vercel-oidc-tokenheader lazily on the first bash call. Because the token is not present inprocess.envat module init, the kitchen-sink can't auto-detect this case — setBASH_PROVIDER=vercelto opt in. - Static access token — set
VERCEL_TOKENandVERCEL_TEAM_IDin your Vercel project's environment variables. (VERCEL_PROJECT_IDis a Vercel system environment variable that's already injected on every deployment, so you don't need to add it yourself.) The kitchen-sink'sselectBashProvider()checks all three together and auto-selects the Vercel provider when present; noBASH_PROVIDERopt-in needed.
See the Vercel Sandbox authentication docs for setup details.
Cost notes for public demos
Vercel Sandbox is billed at $0.128 per vCPU-hour (CPU-active), $0.0212 per GB-hour of provisioned memory, and $0.60 per million sandbox creations. Each visitor to a public demo with no auth wall potentially provisions one sandbox, so costs scale linearly with traffic. The Hobby tier allowance is 5 vCPU-hours, 5,000 creations, and 420 GB-hours of memory per month. Concurrency caps are 10 (Hobby) and 2,000 (Pro/Enterprise).
For public demos without authentication, keep just-bash as the default. Reach for Vercel Sandbox when visitors are authenticated, when you need real package installs, or when you've added per-IP rate limiting upstream.
Forcing a provider
The BASH_PROVIDER environment variable forces the kitchen-sink's selector:
| Value | Effect |
|---|---|
vercel | Vercel Sandbox. SDK fetches OIDC token lazily; errors surface clearly if credentials are missing. |
just-bash | In-memory virtual FS. Default fallback on Vercel without credentials. |
local | Real host shell. Only useful in environments with a writable filesystem. |
moat | MOAT container isolation. Requires the moat CLI on the host — not useful on Vercel. |
When BASH_PROVIDER is unset, the selector auto-detects: Vercel Sandbox if the static-token credentials are present, otherwise just-bash.
# Opt in to real Vercel Sandbox microVMs (requires OIDC enabled OR the token below).
# BASH_PROVIDER=vercel
# Static access token — auto-detect picks `vercel` when both are set.
# (VERCEL_PROJECT_ID is auto-injected by Vercel, no need to set it here.)
# VERCEL_TOKEN=...
# VERCEL_TEAM_ID=team_...
When the adapter can't authenticate, you'll see an error in deploy logs and the chat UI naming the three remediation paths (enable OIDC, set the static triple, or fall back to just-bash). That message replaces the bare Status code 400 is not ok that older versions surfaced.
8. Deploy
From the CLI:
vercel --prod
From Git: Push to your connected repository. Vercel builds and deploys automatically.
Monorepo? Set the root directory in your Vercel project settings to your app's subdirectory (e.g., apps/my-app or examples/hello-chat). Also set:
- Build Command:
cd ../.. && pnpm install && pnpm --filter @flow-state-dev/example-hello-chat build && cd apps/my-app && tsx scripts/migrate.ts(adjust the filter to your package name) - Output Directory:
.next
9. Verify
# 1. Check the API responds
curl https://your-app.vercel.app/api/flows
# 2. Run an action
curl -X POST https://your-app.vercel.app/api/flows/hello-chat/actions/chat \
-H "Content-Type: application/json" \
-d '{"userId": "test", "input": {"message": "Hello"}}'
# 3. Stream the response (use the requestId from step 2)
curl -N https://your-app.vercel.app/api/flows/hello-chat/requests/REQUEST_ID/stream
Serverless timeout limits
Vercel serverless functions have execution time limits:
| Plan | Timeout |
|---|---|
| Hobby | 10 seconds |
| Pro | 60 seconds |
| Enterprise | 900 seconds |
The @flow-state-dev/vercel/config module exports maxDuration = 300. If your Vercel plan's limit is lower, the plan limit takes precedence. The adapter sets the max so that plan upgrades immediately unlock longer execution times without redeploying.
What this means in practice:
- Simple chat flows (single LLM call) usually complete in 5-15 seconds. Fine on any plan.
- Multi-step agent flows with tool calls can take 30-120 seconds. Needs Pro or higher.
- Long-running workflows (research agents, multi-model pipelines) may need a different platform entirely.
If your flows consistently exceed the timeout, consider Railway or Docker instead.
Troubleshooting
SSE stream arrives all at once
If you're not using @flow-state-dev/vercel, make sure your route file exports export const dynamic = "force-dynamic". The adapter handles this automatically.
"Module not found" for @flow-state-dev packages
Add the packages to transpilePackages in next.config.mjs. This is needed for workspace dependencies in a monorepo.
Function timeout on Hobby plan
Your flow takes longer than 10 seconds. Upgrade to Pro (60s limit) or switch to a container-based platform for long-running flows.
Cold start latency
The first request after a period of inactivity takes longer because Vercel starts a new function instance. This is inherent to serverless. The framework initializes quickly (registry + model resolver), but the LLM call itself adds latency. Subsequent requests reuse the warm instance.
CORS errors from a different frontend
If your frontend is on a different domain than the API, you'll need to add CORS headers. The framework doesn't add them by default. Handle this at the Next.js middleware level or by wrapping the handler response.
Environment variable not found
Make sure the variable is set in the Vercel dashboard (not just in .env.local). Vercel doesn't automatically sync local env files. Also verify the variable is set for the correct environment (Production, Preview, or Development).