ZeroStarter

Deploy to Vercel

Ship web and api as two Vercel projects on one database; migrations run on deploy.

Deploy web/next and api/hono as two separate Vercel projects pointed at the same repository. They share one Postgres, but each has its own root directory, env vars, and deploy lifecycle. The api project does one extra thing: its build runs pending database migrations first, on production and canary deploys only, never on PR previews.

The shape

Both apps ship a vercel.json, so Vercel reads the install command, build command, framework, and Bun version automatically once you point a project at the right root. You set two things per project: the Root Directory and the environment variables.

ProjectRoot DirectoryFramework
Backendapi/honoHono
Frontendweb/nextNext.js

Deploy the backend first: the frontend needs its URL.

Migrations run at build time

api/hono/vercel.json puts a migration step ahead of the build:

"buildCommand": "cd ../.. && bun .github/scripts/migrate-on-deploy.ts && turbo build:vercel --filter=@api/hono"

migrate-on-deploy.ts applies pending migrations with bun run db:migrate, but only when VERCEL_ENV=production or the branch is canary. Every other preview (a PR branch) skips it: an unmerged migration must not run against the shared database. The split is deliberate: main (production) and canary are the two branches allowed to change the shared schema; feature branches build and preview without touching it.

Production and canary need a build-time database

Because migrations run during the build, production and canary deploys need a reachable POSTGRES_URL available at BUILD time, not just at runtime. PR previews don't migrate, so they don't need it.

Backend project (deploy first)

  1. Add New Project and select your repository.
  2. Set Root Directory to api/hono.
  3. Add the backend env vars (below).
  4. Deploy, then copy the deployment URL: you need it for the frontend.

.env.example is the full list; at minimum set:

NODE_ENV=production
HONO_APP_URL=https://your-api.vercel.app
HONO_TRUSTED_ORIGINS=https://your-web.vercel.app
BETTER_AUTH_SECRET=your_secret_key
POSTGRES_URL=your_postgres_connection_string
# OAuth (optional; the buttons render only when set)
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret

HONO_PORT (default 4000) is in the schema but unused on Vercel: the serverless runtime owns the port.

Frontend project

  1. Add New Project and select the same repository.
  2. Set Root Directory to web/next.
  3. Add the frontend env vars, using the backend URL from above for NEXT_PUBLIC_API_URL.
  4. Deploy.
NODE_ENV=production
NEXT_PUBLIC_APP_URL=https://your-web.vercel.app
NEXT_PUBLIC_API_URL=https://your-api.vercel.app
# Optional analytics / feedback
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=your_posthog_key
NEXT_PUBLIC_USERJOT_URL=your_userjot_url

NEXT_PUBLIC_NODE_ENV is required by the web env schema but derived from NODE_ENV, so you don't set it separately.

Two projects, two env stores

Env vars are set per project and don't propagate between them, and the two projects deploy independently. When you add a custom domain, update the URL vars on both projects, and update the OAuth callback URLs in GitHub/Google to match your production domains.

Because both projects sit on *.vercel.app (a public suffix, where the browser refuses a shared Domain cookie), the app detects this shape at build and routes sign-in same-origin through the web's /api proxy, so the session cookie lands first-party on the web with no extra setup.

Register the OAuth callback at the web origin

Sign-in completes on the web, so both the app origin and the callback are the web (swap in your web origin), not the api:

# origin (Homepage URL / Authorized origins)
https://your-web.vercel.app
# callback (callback URL / redirect URIs)
https://your-web.vercel.app/api/auth/callback/github
https://your-web.vercel.app/api/auth/callback/google

A custom domain (where web and api share a parent) switches back automatically, with the callback on the api host.

The api infers that web origin from HONO_TRUSTED_ORIGINS, taking the first trusted origin that isn't the api itself. Set HONO_WEB_URL on the api project to name it explicitly when that inference won't do: either because you trust more than one origin (say a preview URL) and don't want it to guess, or because no trusted origin differs from the api at all, in which case sign-in cannot complete until you set it.

WebSockets

The api serves WebSockets on Vercel through Vercel's native support, which needs Fluid compute (on by default for projects created on or after April 23, 2025). hono/bun can't drive the upgrade because Vercel Functions don't run Bun.serve(), so the api switches to the Node adapter (@hono/node-server + ws) when process.env.VERCEL is set; on Bun (local and Docker) it keeps the Bun.serve() path. The live /api/health/ws example (see Type-safe API) works on both. When the two projects sit on a public hosting suffix the client talks to the api through the web's /api proxy, and a Next rewrite does not forward the WebSocket upgrade, so the example falls back to its REST heartbeat there (the badge stays green, without live-over-socket updates); a custom domain, where the browser reaches the api directly, keeps the socket. A single connection is pinned to one function instance and closes when the function reaches its max duration (a few minutes by default), so keep any shared socket state external (for example Redis) and let clients reconnect. See Realtime for the typed client and the reconnect pattern.

Next