Skip to content

Local → production (Neon + Vercel + Cloudflare)

Copy page

This is the end-to-end path from a working local app to a fully serverless production deployment: Neon (managed Postgres) ← Vercel (serverless API + web) → Cloudflare Worker (edge realtime Durable Object) ← every client. It’s the recipe behind the Tabkeep reference deployment, with the sharp edges called out so you don’t rediscover them.

React / Expo web ─┐ ┌─ pull / push (HTTP) ──► Vercel serverless ──► Neon
iOS / Android ───┼─ realtime (WS) ──► CF Worker DO ◄── publish (commit poke) ── Vercel

If your app is realtime-light, you can skip the Worker entirely and rely on pull.intervalMs — but the steps below give you instant cross-device sync.

Before touching the cloud, confirm sync works on your machine: run your server (e.g. an in-process PGlite demo server) and a client, write on one, see it on another. Nizhal’s local and hosted code paths are identical, so a green local run means the only remaining variables are infrastructure.

Create a database and apply your schema. Two things bite here:

  • Use the pooled connection string for serverless. Serverless functions cold-start many instances; the direct endpoint exhausts Neon’s connection limit. Grab the -pooler host:
    Terminal window
    neonctl connection-string <branch> --project-id <id> --database-name <db> --pooled
    # postgresql://USER:PASS@ep-xxxx-pooler.REGION.aws.neon.tech/DB?sslmode=require
  • nizhal migrate layers the engine onto your existing tables — it does not create them. Create your business tables first (your ORM / SQL migrations), then provision:
    Terminal window
    # 1. create business tables (your migrations) — then:
    DATABASE_URL="postgresql://…/DB?sslmode=require" nizhal migrate --config nizhal.config.ts
    A .ts config (and its .ts source imports) loads directly — no build step. Run against an empty database and migrate stops with a message telling you to create your tables first.

Prefer provisioning in code for serverless? Skip the CLI and call storage.provision({ schema, syncRules }) once from a deploy script (after applying your business DDL). Most serverless deploys do this — see apps/tabkeep/scripts/provision-neon.ts.

2. Deploy the realtime Worker (Cloudflare)

Section titled “2. Deploy the realtime Worker (Cloudflare)”
Terminal window
cd node_modules/@nizhal/server/dist/adapters/cloudflare # or your own worker entry
wrangler deploy
wrangler secret put NIZHAL_JWT_SECRET # = your server's bearerTokenAuth secret
wrangler secret put NIZHAL_PUBLISH_SECRET # shared secret for the server→DO publish bridge
wrangler secret put NIZHAL_AUTHORIZATION_URL # REQUIRED — your server origin (https://app.example.com)

Note the worker URL (e.g. https://nizhal-realtime.you.workers.dev).

Expose the Hono server as a serverless function. The interactive-transaction push path runs fine on the Neon pooler; realtime fan-out is awaited in-request, so a frozen lambda never drops it.

// api/index.ts — serverless handler
import { getRequestListener } from "@hono/node-server";
import { postgresStorage } from "@nizhal/server/adapters";
import { cloudflareHttpRealtime } from "@nizhal/server/adapters/cloudflare/realtime";
import { createMyServer } from "../src/server.js";
const storage = postgresStorage({ connectionString: process.env.DATABASE_URL! });
const realtime = cloudflareHttpRealtime({
publishUrl: `${process.env.NIZHAL_WORKER_URL}/_nizhal/publish`,
publishSecret: process.env.NIZHAL_PUBLISH_SECRET!,
});
const server = createMyServer({ db: process.env.DATABASE_URL!, secret: process.env.NIZHAL_JWT_SECRET!, storage, realtime, cors: true });
export default getRequestListener(server.app.fetch);

Set the project env vars (production): DATABASE_URL (the pooled URL), NIZHAL_JWT_SECRET, NIZHAL_PUBLISH_SECRET, and NIZHAL_WORKER_URL — the same secret values you gave the worker.

Hosting the web app same-origin with the API (one Vercel project, with the SPA static + the function) keeps /sync/* and your session endpoint as relative paths — no CORS to configure.

  • Web — build with the Worker host injected, and use the Cloudflare realtime route (createCloudflareSubscribeSource):
    Terminal window
    VITE_NIZHAL_REALTIME_HOST="nizhal-realtime.you.workers.dev" pnpm build
  • React Native — set realtimeHost so realtime connects to the Worker instead of the (nonexistent) serverless /sync/stream:
    createNizhalNitroClient({
    server: "https://app.example.com",
    realtimeHost: "nizhal-realtime.you.workers.dev", // → nitroCloudflareSubscribeSource
    token,
    });

Don’t trust a 200 — exercise the engine end-to-end:

  1. Push → durable commit: a real client addCustomer → confirm the row in Neon (psql).
  2. Pull: a second client pulls and sees it (cursor advances).
  3. Realtime: with a subscriber connected, a push triggers repull:<bucket> within ~1s.
  4. Cross-device: write on web, watch it land on mobile (and vice-versa).

Reusable probes that do exactly this live in apps/tabkeep/examples/: hosted-push-test.ts (push→pull), hosted-realtime-test.ts (publish→subscriber repull).

Gotchas, ranked (all fixable, most now loud)

Section titled “Gotchas, ranked (all fixable, most now loud)”
#SymptomCauseFix
1Realtime silently dead; publish still 204sNIZHAL_AUTHORIZATION_URL unset on the workerset it to your server origin (step 2)
2nizhal migrate errors on a fresh DBbusiness tables don’t exist yetcreate them first; migrate layers the engine on top
3Writes 401 → dead-letter mid-sessionsession token expired, no refreshwire auth.refresh to re-fetch a token on 401
4Connection storms / pool errorsdirect Neon endpoint on serverlessuse the -pooler (pgbouncer) connection string
5RN realtime won’t connectnitro WS points at the server’s /sync/streamset realtimeHost to the Worker (nitroCloudflareSubscribeSource)

Infrastructure and the sync engine are production-grade with this setup — durable, idempotent, realtime, across web + native. The remaining step to a production app is real auth: replace any open demo session endpoint with your auth provider issuing scoped bearer tokens; Nizhal’s token and realtime-authz seams already support it.