Vercel

The Vercel target deploys your app to Vercel's Node 24 runtime on Fluid compute. It emits a Build Output API tree under .vercel/output/, and vercel deploy --prebuilt uploads that tree as-is.

It serves the same edge subset of B4.run as the Hono target. That means no sandbox or workspace tooling, and the stores are durable Postgres rather than local SQLite. If you need a full filesystem or an execution sandbox, use Node and Docker.

Fit checkCopy link to section: Fit check

We test the emitted function three ways:

  • We inspect the Build Output API tree structurally.
  • We import the bundled function and drive it with Web Request objects in a Node 24 process.
  • A gated vercel-native CI lane creates real preview deployments through the pinned Vercel CLI (58.9.0), drives stateful and streaming Agent Protocol traffic against them, and removes them.

That lane proves one source-built and one prebuilt deployment on a hosted Neon database. Test your own project's authentication, regions, quotas and model-provider path.

Before you choose the target, confirm all of these:

  • The app can run without sandbox, workspace, shell, route long-term memory and tool-output offloading.
  • A Postgres pool opened and disposed per request fits your database environment.
  • Every authored route, tool, state module, middleware module and transitive dependency bundles into a single self-contained function directory.
  • You'll test the deployed project itself, rather than trusting a green build.

b4 check applies the same B4_E1005 capability gate to vercel as to hono. Every gate in What the edge cannot serve applies unchanged.

Select the targetCopy link to section: Select the target

vercel is opt-in. Setting build.targets replaces the Node and LangSmith defaults, so list every target you still want:

b4.config.ts
import { config } from "@b4run/cli"
 
export default config({
  build: { targets: ["node", "vercel"] },
})

Declare the packages the emitted function imports. The build prints a notice when any of @b4run/cli, @b4run/postgres-storage, @neondatabase/serverless or hono is missing from the app's package.json. Add them to dependencies so the bundler can resolve them:

bash
npm install @b4run/postgres-storage @neondatabase/serverless hono

The generated stores also import @b4run/postgres-storage/node, which brings in pg as its own dependency. You don't need to declare pg yourself.

Then validate and build:

bash
b4 check
b4 build
b4 verify

The generated app templates already ignore .vercel/. In an existing app, add it to .gitignore. vercel.json stays at the root and is committed.

Emitted artifactsCopy link to section: Emitted artifacts

The target writes one Build Output API tree at .vercel/output/ and reconciles one root vercel.json. A bare build (no build.vercel) emits these files:

  • .vercel/output/config.json is Build Output API version: 3 with a single catch-all route, { "src": "/(.*)", "dest": "/b4" }, that sends every path to the function.
  • .vercel/output/functions/b4.func/.vc-config.json is the Node function metadata: handler: "index.mjs", launcherType: "Nodejs", runtime: "nodejs24.x", supportsResponseStreaming: true.
  • .vercel/output/functions/b4.func/index.mjs is the bundled function. It's a Hono catch-all around createRuntimeFetchHandler, with the static route manifest, the per-request Postgres store factory, every model-provider package and the app's serialized config inlined.
  • vercel.json is the root project configuration, created when missing. An existing file is never modified. When it lacks a required setting, a reference copy goes to .b4/build/vercel.json. It isn't emitted when reconciliation is turned off.

build.vercel adds to that tree: a static/ directory, a functions/<name>.func/ per declared function, and the routes that order them. The runtime function keeps the same b4.func name it has in a bare build.

The function directory is the whole deployable. b4 build bundles the generated runtime with esbuild for node24, then verifies that:

  • every non-builtin import resolves inside b4.func
  • no symlink escapes it
  • the bundle contains no node:module loader or non-literal dynamic import

If a dependency can't be bundled, the build fails and names the missing specifier. Install it as a runtime dependency and rebuild.

The output is published atomically. The tree is staged under .vercel/.b4-vercel-<uuid>/, validated, then renamed over .vercel/output. A prior output is backed up first and restored if publication fails. The rest of .vercel/ (for example the project.json that vercel link writes) is left alone.

Vercel deploys from .vercel/output, so that's the default. To publish the tree somewhere else, set build.vercel.outDir in b4.config.ts, or pass b4 build --out-dir <dir> for one run. The flag wins over the config value.

The path resolves relative to the app root. A directory that would contain the app root is rejected before anything is written, and --out-dir is an error when vercel isn't a configured target. Point it at a staging directory when a later step, rather than build.vercel, combines the function with static assets before deploying.

Unlike the Hono target, this target bundles the intermediate modules.edge.mjs, stores.mjs and app.mjs into index.mjs and removes the staging directory. To see what shipped, inspect the bundle itself.

vercel.json reconciliationCopy link to section: vercel.json reconciliation

Unless you turn it off, the target requires two settings in the root vercel.json:

vercel.json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "buildCommand": "node node_modules/@b4run/cli/dist/index.js build",
  "fluid": true
}
  • buildCommand makes a Git-connected project, or a source vercel deploy, run b4 build on Vercel. The remote build then produces the same .vercel/output tree. The explicit node entry sidesteps package-manager-specific binary shims.
  • fluid: true is required, because the generated stores assume one function instance can serve several requests. The file value applies to old and new projects alike, whatever the Dashboard defaults are.

The reconciliation rules are:

  1. No root file: the target creates it with the content above and reports it as an artifact.
  2. Root file has both settings: the file is left untouched. buildCommand counts when, after collapsing whitespace, it equals the command above.
  3. Root file is missing a setting: the file is yours, so it stays as it is. The recommended content is written to .b4/build/vercel.json, and the build prints a warning naming both files and the missing setting. The build still succeeds.
  4. fluid: false: the build fails. The setting conflicts with the supported lifecycle.
  5. Invalid JSON: the build fails with the parse error, because Vercel cannot consume the file either.

Extra settings in the root file are preserved and remain authoritative.

Opt out for a prebuilt-only projectCopy link to section: Opt out for a prebuilt-only project

A prebuilt flow never runs buildCommand, so a file that only satisfies the reconciler is dead weight. Turn reconciliation off:

b4.config.ts
import { config } from "@b4run/cli"
 
export default config({
  build: {
    targets: ["vercel"],
    vercel: { reconcileVercelJson: false },
  },
})

b4 build then ignores vercel.json entirely, drops it from the artifact list, and no longer fails on a committed fluid: false. The build prints a line saying reconciliation is off.

Reconciliation stays on unless the flag is false. A near miss fails with B4_E1003, so it can't quietly leave reconciliation on. These all count as near misses:

  • a non-boolean value
  • a non-object build.vercel
  • an unknown key inside build.vercel
  • reconcileVercelJson placed directly on build, outside build.vercel

The function is still built for Fluid compute. With the flag off, nothing in your source enforces that, so enable Fluid in the Vercel project settings.

DATABASE_URL and NeonCopy link to section: DATABASE_URL and Neon

The function builds its checkpointer, thread store and permission store per request from DATABASE_URL, read through process.env at invocation time. A missing value fails the request with a message naming the variable. The URL is read at runtime, so the same artifact deploys to preview and production with different databases.

The easiest setup is the Neon integration on the Vercel Marketplace, also on the project's Storage tab. It creates a Neon account if you need one, provisions a Neon project, and adds DATABASE_URL (the pooled connection string) plus DATABASE_URL_UNPOOLED and the PG* components to the selected environments. The generated stores read only DATABASE_URL.

Any other Postgres works too. For a non-Neon host, the stores pick a pooled pg connection automatically. Set the variable under Settings → Environment Variables and redeploy, because runtime variables are captured per deployment.

Model-provider keys and base URLs (OPENAI_API_KEY, OPENAI_BASE_URL, and so on) are read the same way. Keep secret literals out of b4.config.ts, because the JSON-representable part of that file is inlined into the bundle.

The first request in each function instance runs the store migrations under a Postgres advisory lock. Later requests in that instance skip them.

Keep preview and production apartCopy link to section: Keep preview and production apart

By default, the stores use the public schema and the b4 table prefix, so every deployment of the project writes the same public.b4_* tables. On Vercel, that means preview deployments share production's threads and checkpoints.

B4_PG_SCHEMA takes a lowercase identifier or a $NAME reference to another variable, so one project environment variable separates them:

bash
B4_PG_SCHEMA=$VERCEL_ENV

Vercel sets VERCEL_ENV on every deployment, so previews migrate and write preview.b4_* while production gets production.b4_*. B4_PG_TABLE_PREFIX works the same way for the table prefix.

The migration pass creates the schema when it's missing, and each instance tracks its cold-start migration per database, schema and prefix. An unset value keeps public.b4_*. A malformed one fails the request and names the variable, so it never quietly falls back to public. See Namespace preview and production for the full table.

maxDuration and streamingCopy link to section: maxDuration and streaming

The runtime function's .vc-config.json carries handler, launcherType, runtime and supportsResponseStreaming, plus maxDuration when build.vercel.maxDuration sets one. The output validator rejects any other key.

If you leave maxDuration out, the project default applies (Settings → Functions → Function Max Duration in the Vercel Dashboard). With Fluid compute that default is 300 seconds on every plan, and Pro and Enterprise projects can raise it to 800 seconds. Set build.vercel.maxDuration to keep the ceiling in the repository rather than the dashboard.

The runtime function runs the agent, so it's the one a long tool-using run outgrows. Vercel terminates agent runs that stream longer than the effective duration. A function declared under build.vercel.functions takes its own maxDuration and supportsResponseStreaming. Note, a vercel.json functions block has no effect on a prebuilt Build Output tree.

Streaming needs no extra configuration. Agent Protocol runs/stream and AG-UI responses are text/event-stream bodies. They reach the browser incrementally because the runtime function's .vc-config.json always carries supportsResponseStreaming: true.

Without that flag, Vercel's Node launcher buffers the whole body. A deployed UI then shows nothing until the run finishes, and then everything at once. If you see that, check that the flag survived whatever produced the tree. A function you declare under build.vercel.functions has to ask for it. The native lane verifies that a prebuilt deployment delivers events incrementally, with the periodic : ping keepalive comments the runtime emits between them.

Disconnecting a streaming client only detaches that viewer. The run continues, and the generated stores dispose their pool only after it settles. Use POST /threads/:thread_id/cancel to stop work, as described in Client disconnect.

Compose with a frontendCopy link to section: Compose with a frontend

A bare build emits one function behind a catch-all route. That's all a standalone agent service needs. An app that also ships a SPA or its own API routes needs more, and build.vercel describes the rest of the tree so b4 build alone produces the whole deployable:

b4.config.ts
import { config } from "@b4run/cli"
 
export default config({
  build: {
    targets: ["vercel"],
    vercel: {
      static: { dir: "../web/dist", spaFallback: "index.html" },
      functions: {
        api: { entry: "src/api.ts", maxDuration: 30, supportsResponseStreaming: true },
      },
      routes: [{ src: "/api/(.*)", dest: "/api" }],
    },
  },
})

That build publishes static/ from static.dir, a functions/<name>.func/ for each declared function, and a config.json whose routes run in Build Output order: your routes, then { handle: "filesystem" }, then the runtime function, then the SPA fallback.

Two details matter for a frontend:

  • The runtime function is named b4.func. In the Build Output API, a function named index is also served at /, so it would shadow static/index.html. functionName overrides the name, but functionName: "index" together with static fails the build, so you never publish a tree whose root is shadowed.
  • The runtime keeps its own rooted paths. With a SPA fallback, the runtime route is scoped to its own surfaces (/healthz, /readyz, /threads, /agui, /memory), and every other path falls through to the fallback document. The runtime has no base-path option, so compose by path prefix and leave the request path as it is.

b4 build validates the composed tree before publishing it:

  • config.json must be version 3 and still route to the runtime function.
  • Each route must be a shape the Build Output API accepts.
  • Every functions/*.func must be a self-contained Node function whose dependencies resolve inside its own directory.

Every path build.vercel names is checked before .vercel/ is touched. A missing static.dir or function entry fails with the offending build.vercel.* key, and you're never left with a half-written tree.

Assembling the tree yourselfCopy link to section: Assembling the tree yourself

If your frontend build can't be described this way, assemble the tree in your own step, after b4 build and before vercel deploy --prebuilt. Two things support this:

  • build.vercel.outDir publishes the runtime tree to a staging directory. Your assembler copies from there into the real .vercel/output, and never edits the directory Vercel deploys from in place.
  • The output validator checks only that config.json is version 3 and has a route reaching the runtime function. It accepts extra routes and extra top-level keys, so a composed config still validates.

The validator runs on the tree the target stages. Your assembler's output comes later, so getting the final config.json right is up to you. Or deploy the agent as its own Vercel project and point the frontend at its origin. server.cors in b4.config.ts controls cross-origin access.

Prebuilt CI flowCopy link to section: Prebuilt CI flow

A prebuilt deployment uploads .vercel/output without a remote build, so CI controls the Node and B4.run versions that produced the artifact:

bash
b4 build
vercel deploy --prebuilt --yes

vercel deploy --prebuilt needs the project linked. In CI, set VERCEL_TOKEN, VERCEL_ORG_ID and VERCEL_PROJECT_ID as secrets, and the CLI skips the vercel link step. The committed vercel.json is read from the working directory. Runtime environment variables come from the project, not the pipeline. Here's a GitHub Actions workflow:

.github/workflows/deploy.yml
name: Deploy to Vercel
 
on:
  push:
    branches: [main]
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
      VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
      VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
      - run: npm ci
      - run: npx b4 check
      - run: npx b4 build
      - run: npx vercel@58.9.0 deploy --prebuilt --yes --target production

Drop --target production to create a preview deployment. Pin the CLI version like we do in the B4.run repository, because the prebuilt format is an agreement between your artifact and a specific CLI.

A Git-connected project is the alternative. Push a branch, and Vercel runs the committed buildCommand on a clean checkout. In that flow you commit neither .b4/ nor .vercel/output/.

/healthz and /readyzCopy link to section: /healthz and /readyz

GET /healthz is pure liveness. It builds no stores and answers 200 {"status":"ready"} whenever the function serves HTTP, even with DATABASE_URL unset or the database unreachable. It tells you the function is up, and that's all.

GET /readyz covers the database. It builds the request's stores and makes a real query against each of threads, checkpointer and permissions, running lazy migrations on the way. It answers 200 {"status":"ready","checks":{…}} when all three respond, or 503 {"status":"not_ready","checks":{…}} naming each failing dependency. A DATABASE_URL missing from the Vercel project shows up as a failing requestStores, because the per-request factory itself threw.

Hit /readyz after a deploy, and check it when a deployment serves traffic but every run fails. Two caveats carry over from Production Topology:

  • It skips the model provider and the memory store.
  • Unlike every other endpoint, it reports the real cause. Hostnames, ports, database names and driver error codes appear in the body (connection-string credentials are redacted).

Middleware and thread access gate neither probe, so keep both behind the same outer access controls as the rest of the service.

Test the bundle locallyCopy link to section: Test the bundle locally

The function is a default-exported Web Fetch API handler, so it runs under plain Node 24. You don't need a Vercel emulator or a WebSocket proxy. The generated stores pick a driver per request. A .neon.tech host uses @neondatabase/serverless, and every other host, including a local Postgres, gets a pooled pg connection. Point DATABASE_URL at a local database and import the bundle:

bash
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/app \
  node --input-type=module -e '
    const entry = "./.vercel/output/functions/b4.func/index.mjs"
    const { default: app } = await import(entry)
    const ready = await app.fetch(new Request("http://localhost/readyz"))
    console.log(ready.status, await ready.text())
    const created = await app.fetch(new Request("http://localhost/threads", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: "{}",
    }))
    console.log(created.status, await created.text())
  '

If functionName renames the function or build.vercel.outDir moves the tree, adjust the path to match.

Setting B4_PG_DRIVER to neon or pg overrides that detection.

To rehearse the WebSocket path a Neon deployment really uses, put neondatabase/wsproxy in front of the database and set B4_PG_WS_PROXY. It takes host:port, optionally prefixed with ws:// or wss://. Only wss:// keeps TLS on the socket. A value with a path, a query or any other scheme is rejected by name before a pool opens, because the driver appends its own /v1?address= path. Setting B4_PG_WS_PROXY alongside B4_PG_DRIVER=pg is a configuration error, so one value never quietly wins.

Two proxy details are easy to get wrong. The proxy must be reachable at the address you name. And DATABASE_URL must name the Postgres host as the proxy sees it, which may differ from how your shell sees it. The proxy dials the database, and the function only dials the proxy.

Database drivers and B4_PG_WS_PROXY documents the full selection order and the hono differences. selectPostgresDriver and normalizeWsProxy are exported from @b4run/cli/fetch for hand-composed store factories.

To point a real client at it, mount the same handler in a Node HTTP server with @hono/node-server's serve({ fetch: app.fetch }).

Deploy checklistCopy link to section: Deploy checklist

  1. Run b4 check and resolve every B4_E1005 violation.
  2. Declare @b4run/cli, @b4run/postgres-storage, @neondatabase/serverless, hono and every model-provider package in dependencies.
  3. Commit vercel.json with the buildCommand and fluid: true settings, or set build.vercel.reconcileVercelJson: false and enable Fluid in the project settings.
  4. Set DATABASE_URL and provider credentials in the project's environment variables for each deployment environment, plus B4_PG_SCHEMA when preview and production share a database.
  5. Set the project's default function max duration to cover your longest streamed run.
  6. Put outer authentication and tenant authorization around every rooted B4.run path.
  7. Deploy, then check GET /readyz and drive POST /threads, a streamed run and a second request in the same instance against the deployment URL.