Web Layer (SPAs & Branding)
Why two front-ends instead of one? Because they guard two different perimeters: operators administer all tenants from behind Cloudflare Access, while tenant users see only their own org behind Better Auth. Folding both into one app would mean one bundle that can talk to both API surfaces and both auth models — a blast radius no one wants. So the front end is two single-page apps that look alike but answer to different audiences.
The existing apps/web is repurposed into the operator admin panel, and a new
tenant-facing SPA is added for org-scoped users. They share visual primitives through
a new packages/ui, but they diverge on everything that touches a specific worker:
routing, auth, API client, and state. Keeping that line clean is what lets one set of
buttons and dialogs serve two completely separate audiences.
App matrix
Section titled “App matrix”| App | Stack | Served at | Auth | API target | Owner |
|---|---|---|---|---|---|
apps/admin-ui | React SPA | admin.example.com | Cloudflare Access (no login form) | apps/admin | operators |
apps/app | React SPA | *.app.example.com + custom domains | Better Auth cookies + SSO + password | apps/server | tenant users |
packages/ui (new) | React component library | n/a | n/a | n/a | shared |
apps/admin | Hono Worker (API + serves admin-ui) | admin.example.com | CF Access JWT | self / service bindings | infra |
apps/app (worker) | Static-assets Worker | non-public; via service binding | none (public assets) | n/a | infra |
Both web apps build on the same base stack: TanStack Router (file-based, with a
(protected) route group), TanStack Query, Zustand, Tailwind,
@hookform/resolvers plus Zod, and @hey-api/openapi-ts for typed API clients.
apps-admin-ui
Section titled “apps-admin-ui”The apps/admin-ui SPA is the renamed apps/web. Because it now serves operators instead of
tenant users, the existing routes map cleanly onto operator equivalents — the
shape carries over even though the audience changes.
Routes
Section titled “Routes”/— operator dashboard (queue depths, recent audit, active tenant count)/enrollment— one-time landing for the first-loginx-admin-enrollment-tokenclaim/tenants,/tenants/:id,/tenants/:id/hostnames,/tenants/:id/sso/users— cross-tenant user search (rate-limited; row-capped)/audit-logs— cross-tenant audit filter/global-admins—super_adminonly/system/queues,/system/workflows/profile— the operator’s own profile
There is deliberately no /login route: Cloudflare Access intercepts before the
SPA ever loads, so the panel never renders a credential form.
What gets removed from the old apps/web
Section titled “What gets removed from the old apps/web”Repurposing the panel means deleting everything that assumed a tenant user:
- The multi-step login form (
apps/web/src/modules/auth/) is deleted entirely. - 2FA setup flows that target tenant users are deleted — operators get their MFA from the IdP behind Cloudflare Access.
- The existing
dashboard,audit-logs, andusersroutes are repurposed into their cross-tenant operator equivalents.
The enrollment flow
Section titled “The enrollment flow”A brand-new operator has a Cloudflare Access identity but no global_admins row
yet, so the first protected call fails. The SPA turns that failure into a guided
one-time claim:
globalAdminMiddlewarereturns403 { code: "ENROLLMENT_REQUIRED" }.- The SPA’s global error handler navigates to
/enrollment, which shows a single input for the operator’s one-time token. - On submit, the next API call carries
x-admin-enrollment-token: <value>, and the middleware atomically binds the operator’ssubto the pending row. - A successful claim navigates back to
/.
API client
Section titled “API client”The admin SPA generates a typed client from the admin worker’s OpenAPI spec:
{ input: "../admin/openapi.cache.json", // generated by apps/admin's generate-openapi script output: "./src/api.gen/", plugins: ["@hey-api/client-fetch", "@hey-api/sdk", "@tanstack/react-query", "zod"],}The base URL is always https://admin.example.com (or import.meta.env.VITE_ADMIN_URL
for local dev). There is no tenant context to thread through — operators work across
all tenants.
For this to work, apps/admin exports an OpenAPI spec via @hono/zod-openapi, the
same pattern apps/server already uses. The build runs in two steps:
apps/admin#generate-openapiruns the in-process Hono app and writesapps/admin/openapi.cache.json.apps/admin-ui#generate-clientreads that file and emits the typed client intosrc/api.gen/.
This reuses the existing generate-openapi task name from turbo.json rather than
inventing a new one, so the pipeline stays uniform across the two web apps.
apps-app
Section titled “apps-app”The apps/app SPA is the new tenant-facing front end. It runs on *.app.example.com and on tenant
custom domains, and it calls apps/server. Unlike the admin panel, it has a real
login surface, because tenant users authenticate with Better Auth.
Routes
Section titled “Routes”Public routes live outside the (protected) group:
/login— SSO-aware: it fetches/api/tenancy/currentand branches onenforceSSO/accept-invite/:invitationId— public; orchestrates user creation plus sign-in (see Admin Panel & Operators)/forgot-password,/reset-password— Better Auth’s existing OTP flow
Protected routes live inside the (protected) group and redirect to /login when
there is no session:
/,/dashboard/settings/profile,/settings/security(2FA management)/org/members— invite or remove members; uses Better Auth’s existing org plugin endpoints/org/settings— org name and per-tenant branding/org/settings/sso— register, edit, or delete SSO providers; toggleenforce_sso/org/settings/domains— add, verify, or remove custom hostnames/org/audit-logs— tenant-scoped audit (including operator-attribution rows)
Tenant context, resolved at runtime
Section titled “Tenant context, resolved at runtime”In a single-tenant app you’d bake the tenant and the API URL into the build. You
can’t here: there are thousands of tenants and they bring their own custom domains, so
you’d need a rebuild per tenant. The fix is runtime tenant resolution — the SPA
ships one identical artifact and, on boot, reads window.location.host (the domain
the browser is already on) and asks the server “which tenant is this host?” The
mental model: the bundle is tenant-agnostic; the URL the user typed is the tenant
identifier.
export type TenantInfo = { organizationId: string; slug: string; enforceSSO: boolean; providers: Array<{ providerId: string; label: string }>; branding: { logoUrl?: string; primaryColor?: string; appName: string };};
export async function resolveTenant(): Promise<TenantInfo | null> { const headers: Record<string, string> = {}; if (import.meta.env.DEV && import.meta.env.VITE_DEV_TENANT_SLUG) { headers["x-dev-tenant-slug"] = import.meta.env.VITE_DEV_TENANT_SLUG; } const r = await fetch(`${window.location.origin}/api/tenancy/current`, { credentials: "include", headers }); if (!r.ok) return null; return r.json() as Promise<TenantInfo>;}The bootstrap in main.tsx resolves the session and the tenant in parallel, bails
out early with a not-found screen when the host doesn’t map to a tenant, and applies
branding before the router ever renders:
const [session, tenant] = await Promise.all([ queryClient.ensureQueryData(sessionQueryOptions).catch(() => null), resolveTenant(),]);if (!tenant) { renderTenantNotFound(); return; }applyBranding(tenant.branding);renderRouter({ session, tenant });Better Auth client
Section titled “Better Auth client”The same runtime-host principle drives the auth client. Its baseURL is
window.location.origin, so the cookie and the API call always match the host the
user is actually on:
export const authClient = createAuthClient({ baseURL: window.location.origin, // NOT a build-time env var basePath: "/api/auth", plugins: [organizationClient(), twoFactorClient(), ssoClient()],});A build-time VITE_SERVER_URL would force a separate rebuild per tenant, which is
unworkable once tenants bring their own custom domains. window.location.host is the
tenant identifier, so reading it at runtime is what keeps a single artifact serving
everyone.
The apps/app worker
Section titled “The apps/app worker”The worker itself does nothing but hand requests to its static assets:
export default { fetch: (request: Request, env: { ASSETS: Fetcher }) => env.ASSETS.fetch(request),};{ "name": "app", "main": "src/index.ts", "compatibility_date": "2026-04-01", "workers_dev": false, "preview_urls": false, "assets": { "directory": "./dist", "binding": "ASSETS", "not_found_handling": "single-page-application" } // No public routes — reachable only via apps/server's STATIC_ASSETS binding.}The not_found_handling: "single-page-application" setting is what lets a hard
reload of a client-side route like /dashboard resolve back to index.html.
Cache-Control and ETag pass through the service binding unchanged.
apps/server proxies non-API paths
Section titled “apps/server proxies non-API paths”The tenant SPA is never reached directly. apps/server serves it: anything under
/api/ falls through to the API handlers, and everything else is fetched from the
static-assets worker.
app.get("*", async (c, next) => { const path = new URL(c.req.url).pathname; if (path.startsWith("/api/")) return next(); // API handlers downstream return c.env.STATIC_ASSETS.fetch(c.req.raw); // SPA assets});This is exactly what makes custom hostnames “just work” for the SPA: app.acme.com
lands on apps/server through the Cloudflare for SaaS fallback origin, and its
non-API paths are proxied to apps/app’s assets — no per-tenant build, no per-tenant
route.
packages-ui
Section titled “packages-ui”The new packages/ui package holds the shared UI primitives and the Tailwind preset that both web
apps consume. The goal is one place for the look-and-feel so the two SPAs stay
visually consistent without coupling their data layers.
What’s in scope
Section titled “What’s in scope”- All Radix/shadcn component wrappers —
Button,Dialog,DataTable,DropdownMenu,Sheet,Tabs,Toast, and so on. - Stateless utilities:
cn(a clsx wrapper),useDialogState,useMobile,useTableUrlState. - Common composition patterns:
ConfirmDialog,EmptyState,LoadingState,ErrorBoundary,FullPageError. - The Tailwind preset (
packages/ui/tailwind.preset.ts) — both apps extend it so brand tokens stay consistent. - Storybook — the single source of truth for the primitives. It lives in
packages/uiand is not duplicated per app.
What’s not in scope
Section titled “What’s not in scope”Anything that knows about a specific worker’s API or auth model stays in the consuming app:
- Query hooks (TanStack Query options) — they diverge between apps, with different APIs and different cache keys.
- Zustand stores — they diverge.
- The Better Auth client — the two apps use different URL strategies.
- Route infrastructure (the TanStack Router
routeTree, route guards) — diverges. - Generated API types — each app generates from its own worker’s OpenAPI.
The dividing line is simple: anything stateless and visual is shared, which is a
consistency win; anything that imports from react-query, better-auth/client, or
api.gen/ stays in the app, which is a decoupling win.
Per-tenant branding
Section titled “Per-tenant branding”A tenant should be able to recognize its own product the moment the login page loads. Each organization carries three branding columns:
organization.branding_logo_url— text; a URL pointing into the R2 bucketorganization.branding_primary_color— text; a CSS color validated by a Zod regexorganization.branding_app_name— text; defaults toorganization.name
These are returned by /api/tenancy/current under the branding key, alongside
{ organizationId, slug, enforceSSO, providers }, where branding is
{ logoUrl?, primaryColor?, appName }.
Logo hosting
Section titled “Logo hosting”Logos live in R2 (Cloudflare’s S3-style object store) rather than in the database, so the database row stores only a URL and large binaries never bloat queries:
- A Cloudflare R2 bucket,
BRANDING_ASSETS, is bound toapps/server. - A tenant admin uploads via
POST /api/org/branding/logo— a plain multipart upload that goes throughapps/server(which validates and writes to R2), not a presigned direct-to-bucket URL. Limits: max 256KB, PNG/SVG/WebP only. - Objects are stored at
R2:{orgId}/logo-{cuid}.{ext}. - They are served through a Cloudflare custom domain on the bucket
(
branding.example.com), and the CSPimg-srcdirective allows that host.
Primary-color validation
Section titled “Primary-color validation”The color is validated with a Zod regex at the API boundary so a tenant admin can’t smuggle a payload into a CSS value:
const primaryColor = z.string().regex(/^(#[0-9a-f]{3,8}|rgb\(.+\)|hsl\(.+\))$/i);Applying the branding
Section titled “Applying the branding”Branding is applied by setting CSS custom properties and the document title — never by injecting markup:
function applyBranding(b: TenantInfo["branding"]) { if (b.primaryColor) document.documentElement.style.setProperty("--brand-primary", b.primaryColor); document.title = b.appName;}The logo itself is rendered through a React component —
<TenantLogo src={tenant.branding.logoUrl} alt={tenant.branding.appName} /> — on the
login page, in the sidebar, and anywhere else the brand appears.
Local development
Section titled “Local development”Each SPA runs against its target worker locally, gated so dev-only shortcuts can never be reached in production.
apps/app
Section titled “apps/app”bun devrunsvite --port=3000..env.developmentsetsVITE_DEV_TENANT_SLUG=acme.- Vite proxies
/api/*tohttp://localhost:8787(the localapps/server). - The tenant resolves via an
X-Dev-Tenant-Slug: acmeheader, which the server only honors whenNODE_ENV=developmentand theALLOW_DEV_TENANT_HEADER=trueCloudflare Secret is set.
apps/admin-ui
Section titled “apps/admin-ui”bun devrunsvite --port=3001.- Vite proxies
/api/*tohttp://localhost:8788(the localapps/admin). - Operator auth uses
LOCAL_DEV_ADMIN_EMAIL, gated byNODE_ENV=developmentand theALLOW_DEV_ADMIN_AUTH=truesecret.
Seeding a local database
Section titled “Seeding a local database”bun run seed:dev creates one org with slug acme, creates one global_admin
matching LOCAL_DEV_ADMIN_EMAIL with role super_admin, and is idempotent (it
UPSERTs keyed on email and slug). A CI guard rejects any deploy whose merged config
still has NODE_ENV !== "production".
Deployment
Section titled “Deployment”Build pipeline
Section titled “Build pipeline”Turborepo wires the two web apps to their target workers so the typed client is always regenerated before the SPA builds:
flowchart LR subgraph Tenant["Tenant app"] SO["apps/server#generate-openapi"] --> AC["apps/app#generate-client"] AC --> AB["apps/app#build"] AB --> AD["wrangler deploy"] end subgraph Admin["Admin app"] AdO["apps/admin#generate-openapi"] --> AdC["apps/admin-ui#generate-client"] AdC --> AdB["apps/admin-ui#build"] AdB --> AdD["apps/admin#deploy"] end
turbo.json declares the generate-client task with
dependsOn: ["^generate-openapi"]. The caret means “upstream package dependencies”,
and it resolves correctly because each web app’s package.json depends on its target
worker’s package.
Topology
Section titled “Topology”- The
apps/admindeploy ships both the worker andapps/admin-ui/dist/(the assets-binding directory is../admin-ui/dist). apps/appdeploys as a separate static-assets worker, reachable only viaapps/server’sSTATIC_ASSETSservice binding.apps/serverandapps/authdeploy independently with no asset payloads.
The apex page
Section titled “The apex page”The wildcard’s apex, app.example.com, is reachable but maps to no tenant. v1 ships a
static “Find your team” landing page from apps/app/dist/apex/:
- An email input looks up org memberships and redirects to
{slug}.app.example.com/login. - A forgot-password recovery link runs the same lookup and routes to the tenant subdomain.
It is served whenever parseHostname returns kind: "apex". The membership lookup is
a v2 nice-to-have; v1 can ship a purely static page with no backend.
Gotchas worth keeping in mind
Section titled “Gotchas worth keeping in mind”workers_dev: falseon every worker, includingapps/app. Otherwise the static-assets worker is reachable atapp.<account>.workers.dev, outside the tenant routing.not_found_handling: "single-page-application"onapps/appis non-negotiable for TanStack Router’s client-side routes.- Don’t share generated API types across apps. Each app’s
api.gen/is pinned to its target worker’s deploy cycle. - The auth client’s
baseURLiswindow.location.origin, never a build-time env var. - Apply CSS via
setProperty, not<style>injection — strict-CSP compatibility. - Logo upload sniffs the content-type from bytes, not from the multipart header.
/sso/callbackis not anapps/approute. Better Auth handles the callback at/api/auth/sso/callback/{providerId}onapps/server.- Storybook lives in
packages/uionly — don’t duplicate it per app. - Local dev relies on two env signals —
NODE_ENV=developmentand a separateALLOW_*secret. Single-signal gates are forgeable.