React Starter Kit runs on three Cloudflare Workers connected by service bindings. A single domain receives all traffic – the web worker routes each request to the right destination without any cross-worker public URLs.
sequenceDiagram
participant Browser
participant Web as Web Worker
participant App as App Worker
participant API as API Worker
participant DB as Neon PostgreSQL
Browser->>Web: GET /
alt auth-hint cookie present
Web->>App: service binding
App-->>Web: SPA (dashboard)
else no cookie
Web-->>Browser: marketing page
end
Browser->>Web: GET /settings
Web->>App: service binding
App-->>Web: SPA assets
Browser->>Web: POST /api/trpc/user.me
Web->>API: service binding
API->>DB: Hyperdrive
DB-->>API: query result
API-->>Web: JSON response
Web-->>Browser: JSON response
| Worker | Workspace | Purpose | Has nodejs_compat |
|---|---|---|---|
| web | apps/web |
Edge router – receives all traffic, routes to app/api | No |
| app | apps/app |
SPA static assets (React, TanStack Router) | No |
| api | apps/api |
Hono server – tRPC, Better Auth, webhooks | Yes |
The web worker is the only worker with a public route (example.com/*). It decides where each request goes:
/api/* – forwarded to the API worker/login, /signup, /settings, /analytics, /reports, /_app/* – forwarded to the app worker/ – routed by auth hint cookie (app if signed in, marketing site if not)// apps/web/worker.ts (simplified)
app.all("/api/*", (c) => c.env.API_SERVICE.fetch(c.req.raw));
app.all("/login*", (c) => c.env.APP_SERVICE.fetch(c.req.raw));
app.on(["GET", "HEAD"], "/", async (c) => {
const hasAuthHint =
getCookie(c, "__Host-auth") === "1" || getCookie(c, "auth") === "1";
const upstream = await (hasAuthHint ? c.env.APP_SERVICE : c.env.ASSETS).fetch(
c.req.raw,
);
// ...
});
A static asset worker with not_found_handling: "single-page-application" – any path that doesn’t match a file returns index.html, enabling client-side routing via TanStack Router.
The app worker has no custom worker script. It is accessed only through service bindings from the web worker.
Runs the Hono HTTP server with the following middleware chain:
// apps/api/worker.ts (simplified)
worker.onError(errorHandler);
worker.notFound(notFoundHandler);
worker.use(secureHeaders());
worker.use(requestId({ generator: requestIdGenerator }));
worker.use(logger());
// Initialize shared context
worker.use(async (c, next) => {
const db = createDb(c.env.HYPERDRIVE_CACHED);
c.set("db", db);
c.set("dbDirect", createDb(c.env.HYPERDRIVE_DIRECT));
c.set("auth", createAuth(db, c.env));
await next();
});
worker.route("/", app); // Mounts tRPC + auth + health routes
Primary endpoints:
| Path | Handler |
|---|---|
/api/auth/* |
Better Auth (login, signup, sessions, OAuth callbacks) |
/api/trpc/* |
tRPC procedures (batching enabled) |
/api |
API info (name, version, endpoint list) |
/health |
Health check |
Service bindings let workers call each other directly over Cloudflare’s internal network – no HTTP round-trip through the public internet.
// apps/web/wrangler.jsonc
"services": [
{ "binding": "APP_SERVICE", "service": "example-app" },
{ "binding": "API_SERVICE", "service": "example-api" }
]
::: warning Service bindings are non-inheritable in Wrangler – they must be declared in every environment block. Forgetting this causes staging/preview workers to bind to production services. :::
Naming convention: <project>-<worker>-<env> (e.g. example-api-staging). See Edge > Service Bindings for the full per-environment config.
The API worker connects to Neon PostgreSQL via Cloudflare Hyperdrive – a connection pool that sits between Workers and your database.
Two bindings are available:
| Binding | Caching | Use case |
|---|---|---|
HYPERDRIVE_CACHED |
Enabled | Default reads – most queries go here |
HYPERDRIVE_DIRECT |
Disabled | Writes and reads that need fresh data |
Both bindings are initialized in the API worker middleware and available on every request context as db and dbDirect. See Database for schema and query patterns.
The / route serves two different experiences – a marketing page for visitors and the app dashboard for signed-in users. The web worker needs a fast signal to choose without owning auth logic.
How it works: Better Auth sets a lightweight __Host-auth=1 cookie on sign-in and clears it on sign-out. The web worker checks only for cookie presence – it never validates sessions. If the cookie exists, the request goes to the app worker; otherwise it serves the marketing page.
This cookie is a routing hint only, not a security boundary. A false positive (stale cookie) results in one extra redirect to /login – the app worker validates the real session.
::: info
In local development the cookie is named auth (HTTP), since browsers reject the __Host- prefix without HTTPS.
:::
See ADR-001 for the full decision record and Sessions & Protected Routes for the auth flow.
| Environment | Workers | Domain | Database | Deploy command |
|---|---|---|---|---|
| Development | wrangler dev |
localhost:5173 |
Dev branch | bun dev |
| Preview | *-preview |
preview.example.com |
Preview branch | wrangler deploy --env preview |
| Staging | *-staging |
staging.example.com |
Staging branch | wrangler deploy --env staging |
| Production | * (no suffix) |
example.com |
Main branch | wrangler deploy |
Each environment has its own Hyperdrive bindings, service binding targets, and APP_ORIGIN / ALLOWED_ORIGINS variables. See Edge > Service Bindings for the full wrangler config.
The workspaces must build in dependency order:
email → web → api → app
Email templates are compiled first because the API server imports them. The bun build command handles this automatically.
nodejs_compat enabled.