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, toasts
    • useDialogStore — global dialog state
  • React Query for server state (with tenantId in 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/)

FilePurpose
client.tsPostgres connection pool
withAuthDb.tsJWT → Postgres session binding
queries/*Drizzle query functions
schema/*Table definitions

7. Supabase (lib/supabase/)

  • Auth only — never used for database queries
  • client.ts — browser client
  • server.ts — RSC / API route client
  • middleware.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
  → Response

Tenant 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:

  1. Read cookie hint
  2. Fetch user's tenants via RLS
  3. If cookie matches a valid tenant → use it
  4. Otherwise pick owner tenant or first available
  5. Return resolved tenantId (does not write cookie)

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 needed

Key Files

FilePurpose
lib/tenants/activeTenant.tsCookie read/write helpers
lib/tenants/resolveActiveTenant.tsRead-only tenant resolution
app/api/tenants/sync/route.tsCookie sync endpoint
app/api/tenants/switch/route.tsManual tenant switch
components/navigations/app-store-hydrator.tsxClient 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)
      └── Children

Admin Layout (app/(admin)/layout.tsx)

Server Component that:

  1. Calls requireUser() to verify auth
  2. Calls withAuthDb() to fetch tenants + resolve active tenant
  3. Renders AppStoreHydrator to push server data to Zustand
  4. Renders sidebar and page content

Core Migrations

These migrations are critical and should never be deleted:

FilePurpose
supabase/migrations/0002_rls.sqlRLS policies
supabase/migrations/0003_user_tenant_trigger.sqlAuto user + tenant creation on signup

See supabase/migrations/README.md for guidance on adding new migrations.

On this page