Skip to content

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.

AppStackServed atAuthAPI targetOwner
apps/admin-uiReact SPAadmin.example.comCloudflare Access (no login form)apps/adminoperators
apps/appReact SPA*.app.example.com + custom domainsBetter Auth cookies + SSO + passwordapps/servertenant users
packages/ui (new)React component libraryn/an/an/ashared
apps/adminHono Worker (API + serves admin-ui)admin.example.comCF Access JWTself / service bindingsinfra
apps/app (worker)Static-assets Workernon-public; via service bindingnone (public assets)n/ainfra

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.

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.

  • / — operator dashboard (queue depths, recent audit, active tenant count)
  • /enrollment — one-time landing for the first-login x-admin-enrollment-token claim
  • /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-adminssuper_admin only
  • /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.

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, and users routes are repurposed into their cross-tenant operator equivalents.

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:

  1. globalAdminMiddleware returns 403 { code: "ENROLLMENT_REQUIRED" }.
  2. The SPA’s global error handler navigates to /enrollment, which shows a single input for the operator’s one-time token.
  3. On submit, the next API call carries x-admin-enrollment-token: <value>, and the middleware atomically binds the operator’s sub to the pending row.
  4. A successful claim navigates back to /.

The admin SPA generates a typed client from the admin worker’s OpenAPI spec:

apps/admin-ui/openapi-ts.config.ts
{
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:

  1. apps/admin#generate-openapi runs the in-process Hono app and writes apps/admin/openapi.cache.json.
  2. apps/admin-ui#generate-client reads that file and emits the typed client into src/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.

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.

Public routes live outside the (protected) group:

  • /login — SSO-aware: it fetches /api/tenancy/current and branches on enforceSSO
  • /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; toggle enforce_sso
  • /org/settings/domains — add, verify, or remove custom hostnames
  • /org/audit-logs — tenant-scoped audit (including operator-attribution rows)

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.

apps/app/src/lib/tenant.ts
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:

apps/app/src/main.tsx
const [session, tenant] = await Promise.all([
queryClient.ensureQueryData(sessionQueryOptions).catch(() => null),
resolveTenant(),
]);
if (!tenant) { renderTenantNotFound(); return; }
applyBranding(tenant.branding);
renderRouter({ session, tenant });

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:

apps/app/src/lib/auth-client.ts
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 worker itself does nothing but hand requests to its static assets:

apps/app/src/index.ts
export default {
fetch: (request: Request, env: { ASSETS: Fetcher }) => env.ASSETS.fetch(request),
};
apps/app/wrangler.jsonc
{
"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.

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.

apps/server/src/server.ts
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.

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.

  • 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/ui and is not duplicated per app.

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.

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 bucket
  • organization.branding_primary_color — text; a CSS color validated by a Zod regex
  • organization.branding_app_name — text; defaults to organization.name

These are returned by /api/tenancy/current under the branding key, alongside { organizationId, slug, enforceSSO, providers }, where branding is { logoUrl?, primaryColor?, appName }.

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 to apps/server.
  • A tenant admin uploads via POST /api/org/branding/logo — a plain multipart upload that goes through apps/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 CSP img-src directive allows that host.

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:

apps/server — branding validation
const primaryColor = z.string().regex(/^(#[0-9a-f]{3,8}|rgb\(.+\)|hsl\(.+\))$/i);

Branding is applied by setting CSS custom properties and the document title — never by injecting markup:

apps/app/src/lib/branding.ts
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.

Each SPA runs against its target worker locally, gated so dev-only shortcuts can never be reached in production.

  • bun dev runs vite --port=3000.
  • .env.development sets VITE_DEV_TENANT_SLUG=acme.
  • Vite proxies /api/* to http://localhost:8787 (the local apps/server).
  • The tenant resolves via an X-Dev-Tenant-Slug: acme header, which the server only honors when NODE_ENV=development and the ALLOW_DEV_TENANT_HEADER=true Cloudflare Secret is set.
  • bun dev runs vite --port=3001.
  • Vite proxies /api/* to http://localhost:8788 (the local apps/admin).
  • Operator auth uses LOCAL_DEV_ADMIN_EMAIL, gated by NODE_ENV=development and the ALLOW_DEV_ADMIN_AUTH=true secret.

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".

Turborepo wires the two web apps to their target workers so the typed client is always regenerated before the SPA builds:

Turborepo build pipeline
Rendering diagram…

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.

  • The apps/admin deploy ships both the worker and apps/admin-ui/dist/ (the assets-binding directory is ../admin-ui/dist).
  • apps/app deploys as a separate static-assets worker, reachable only via apps/server’s STATIC_ASSETS service binding.
  • apps/server and apps/auth deploy independently with no asset payloads.

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.

  • workers_dev: false on every worker, including apps/app. Otherwise the static-assets worker is reachable at app.<account>.workers.dev, outside the tenant routing.
  • not_found_handling: "single-page-application" on apps/app is 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 baseURL is window.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/callback is not an apps/app route. Better Auth handles the callback at /api/auth/sso/callback/{providerId} on apps/server.
  • Storybook lives in packages/ui only — don’t duplicate it per app.
  • Local dev relies on two env signalsNODE_ENV=development and a separate ALLOW_* secret. Single-signal gates are forgeable.