Architecture
Architecture
The foundation is organized in clearly defined layers, each with a specific responsibility. This keeps AI-generated code predictable and prevents architectural drift.
Layers
1. App Router (app/)
- Routing via Next.js App Router
- Route groups:
(marketing),auth/,(admin) - Server Components by default
- Client Components only when required
2. Middleware (middleware.ts)
- Session synchronization via Supabase
- Auth-based routing (redirect unauthenticated users)
- No database access, no tenant resolution
3. Components (components/)
- Presentational only
- Data passed via props
- No data fetching, no business logic
4. Client State (store/, context/)
- UI state only (modals, filters, theme, toasts)
- Zustand stores:
appStore— user, tenants, activeTenant, toastsuseDialogStore— global dialog state
- React Query for server state (with
tenantIdin queryKey) - Never duplicate server data in Zustand
5. API Routes (app/api/)
- Thin orchestration layer
- Pattern:
requireUser() → withAuthDb(jwt) → query(db) → response - No authorization logic (RLS handles it)
6. Database Layer (lib/db/)
| File | Purpose |
|---|---|
client.ts | Postgres connection pool |
withAuthDb.ts | JWT → Postgres session binding |
queries/* | Drizzle query functions |
schema/* | Table definitions |
7. Supabase (lib/supabase/)
- Auth only — never used for database queries
client.ts— browser clientserver.ts— RSC / API route clientmiddleware.ts— edge middleware client
Request Flow
Here's how a typical request flows through the system:
Browser
→ Middleware (getUser() for auth check, routing)
→ API Route
→ requireUser()
→ getUser() [authenticates with Supabase Auth server]
→ getSession() [only for access_token]
→ withAuthDb(session.access_token)
→ Drizzle Query
→ PostgreSQL + RLS
→ ResponseTenant Resolution
Active tenant is stored in a cookie (active_tenant_id).
Read-Only Resolution (Server Components)
resolveActiveTenantId(db) is read-only and safe to call from Server Components:
- Read cookie hint
- Fetch user's tenants via RLS
- If cookie matches a valid tenant → use it
- Otherwise pick owner tenant or first available
- Return resolved
tenantId(does not write cookie)
Cookie Sync (Lazy Sync Pattern)
Next.js 15 does not allow cookie modification in Server Components. The cookie is synced via an API call on client mount:
Layout (Server Component)
→ resolveActiveTenantId(db) [read-only]
→ pass activeTenantId to AppStoreHydrator
AppStoreHydrator (Client Component)
→ mount
→ POST /api/tenants/sync { tenantId }
→ Route Handler sets cookie if neededKey Files
| File | Purpose |
|---|---|
lib/tenants/activeTenant.ts | Cookie read/write helpers |
lib/tenants/resolveActiveTenant.ts | Read-only tenant resolution |
app/api/tenants/sync/route.ts | Cookie sync endpoint |
app/api/tenants/switch/route.ts | Manual tenant switch |
components/navigations/app-store-hydrator.tsx | Client sync on mount |
The cookie is a hint, not trusted. RLS validates actual access.
App Initialization
Root Layout (app/layout.tsx)
QueryClientProviderWrapper
└── ThemeProvider
└── ConfirmProvider
└── ToastProvider
└── DialogManager
└── AppInitializer (loads user → appStore)
└── ChildrenAdmin Layout (app/(admin)/layout.tsx)
Server Component that:
- Calls
requireUser()to verify auth - Calls
withAuthDb()to fetch tenants + resolve active tenant - Renders
AppStoreHydratorto push server data to Zustand - Renders sidebar and page content
Core Migrations
These migrations are critical and should never be deleted:
| File | Purpose |
|---|---|
supabase/migrations/0002_rls.sql | RLS policies |
supabase/migrations/0003_user_tenant_trigger.sql | Auto user + tenant creation on signup |
See supabase/migrations/README.md for guidance on adding new migrations.