Architecture
System overview, apps, packages, database schema, auth flow, and deployment.
Overview
Airestate is a multi-tenant SaaS for real estate agents. Agents use AgentOS (back-office) to manage listings, clients, and their public website. Each agent's website runs on a custom domain, showcasing their listings with search, maps, and SEO.
Apps
AgentOS apps/agentos → airestate.app
Agent back-office (SPA, no SEO)
- Route groups:
(dashboard),(explorer),(admin),(auth) - Dashboard routes: /listings, /clients, /inbox, /website, /analytics, /team, /settings, /docs
- Listing CRUD: /listings/create, /listings/[id]/edit
- Auth routes: /login, /signup, /join (invite acceptance)
- Auth state managed via AuthProvider React Context
- In-app documentation at /docs with sidebar navigation and search
Website apps/website → custom domains
Public listing website (ISR/SSG, SEO-critical)
- One codebase serves all tenants via hostname-based routing
- Themed per workspace via CSS variables from website_config
- Routes: /, /listings, /listings/[slug]
- ISR: revalidate=3600 on listings and detail pages
Designer apps/designer → localhost:3002
Design system gallery (tokens, components, views, tenants)
- Browse all tokens with visual previews at /tokens
- Component gallery with live previews at /components
- Material recipes at /tokens/materials
- Tenant branding preview at /tenants
Packages
| Package | Purpose | Key Exports |
|---|---|---|
| packages/db | Drizzle schema, tenant isolation, migrations | forWorkspace(), forAdmin(), forSystem(), schema |
| packages/ui | Shared React components for both apps | PropertyCard, PropertyGallery, SearchFilters, ExplorerProvider, formatPrice |
| packages/auth | Supabase Auth configuration and helpers | createClient(), createServerComponentClient() |
| packages/permissions | RBAC engine + plan limits | can(), authorize(), enforcePlanLimit() |
| packages/types | Shared TypeScript types | Property, PropertyWithImages, PropertyFilters, WebsiteConfig |
| packages/tokens | Design token definitions, CSS generator, tenant overrides | TOKEN_DEFINITIONS, MATERIALS, generateCSS() |
| packages/config | Shared ESLint, TS configs | Base tsconfig, library tsconfig |
Database
Provider: Supabase Postgres + PostGIS | ORM: Drizzle | Multi-tenancy: Discriminator column (workspace_id) with application-level enforcement
Schema Tables
workspaceswebsite_configsusersuser_permissionsclientsinvitationsotp_codespropertiesproperty_imagesproperty_agentsbuildingstagsneighborhoodsvideospage_viewsrevalidation_queueProperty Data Model Highlights
- Location: lat/lng (doublePrecision), PostGIS location (auto-populated via trigger)
- Price: bigint stored in cents, priceCurrency (3-char ISO code)
- Tags: 5 JSONB arrays (collections, features, amenities, facilities, lifestyles) with GIN indexes
- Cover image: auto-synced via DB trigger from property_images.isPrimary
- Classification: typeTerm (Sale/Rent), propertyType, propertySubType
Key Indexes
- GIN indexes on all 5 JSONB tag arrays
- Partial index on is_visible = true for public queries
- Unique partial index on property_images(property_id) WHERE is_primary = true
- Composite index on (workspace_id, status) and (workspace_id, slug)
Tenant Isolation
forWorkspace(workspaceId) → scoped queries (default for all app code)forAdmin(role) → cross-tenant queries (superadmin only)forSystem() → migrations, seeds, website data layerAuth
Provider: Supabase Auth (passwordless OTP) | Email: Resend API
- Agents/Owners: passwordless 6-digit OTP via email
- Clients: magic link (email)
- No passwords stored -- fully passwordless
OTP Flow
- User enters email → POST /api/auth/send-otp generates 6-digit code, stores in otp_codes table, sends via Resend
- User enters code → POST /api/auth/verify-otp validates code, calls admin.generateLink() → returns token_hash
- Client exchanges token_hash via supabase.auth.verifyOtp() → Supabase session cookie set
- Middleware checks Supabase session → redirects unauthenticated to /login
- API routes call getAuthUser() → AuthUser with id, email, workspaceId, role, permissions
OTP Security
- 10-minute expiry, max 5 verification attempts per code
- 60-second rate limit between OTP requests per email
- crypto.randomInt() (CSPRNG) for code generation
- Email enumeration prevention: send-otp always returns success
Permissions
Platform role: superadmin (bypasses all) or member (default). Workspace permissions are toggle-based per user.
| Category | Permission | Description |
|---|---|---|
| Admin | workspace_master | Full access, at least 1 required |
| Admin | manage_members | Invite/edit/remove teammates |
| Admin | plan_billing | Manage billing and plan |
| Admin | analytics | Workspace-wide analytics |
| Agent | explorer | View all properties + insights |
| Agent | clients_full | All clients, can reassign |
| Agent | clients_restricted | Only assigned clients |
| Agent | listing_manager | Create/edit/publish listings |
| Agent | marketing_hub | Publish to social/website |
Plan Limits
| Limit | Free | Pro | Business |
|---|---|---|---|
| Properties | 10 | 100 | 1,000 |
| Users | 1 | 5 | 25 |
| Clients | 20 | 200 | 2,000 |
| Images | 50 | 500 | 5,000 |
| Custom Domain | No | Yes | Yes |
| Analytics | No | Yes | Yes |
Key Decisions
| Decision | Choice | Why |
|---|---|---|
| Two apps, not one | AgentOS + Website | Different rendering models (SPA vs ISR) |
| Database | Supabase | Integrated platform (DB + Auth + Storage) |
| ORM | Drizzle | Edge-compatible, transparent SQL |
| Multi-tenancy | App-level isolation | Explicit, testable, debuggable |
| Image storage | property_images table | Extensible (alt text, captions, sort order) |
| Search | Postgres search V1 | 50-500 listings/workspace, no external search needed |
| Map hover sync | React Context | Scales to 15+ shared components without drilling |
| ISR strategy | Debounced via queue | Prevents storms with many tenants |
| Price storage | Cents (bigint) | Avoids floating-point rounding in financial calcs |
| Tag storage | JSONB arrays + GIN | Fast reads, simpler queries for 5 tag types |
| Auth method | Passwordless OTP | Better security, no password resets, simpler UX |
| Permissions | Toggle-based | Granular control, clear UI, no ambiguous roles |
Deployment
- Hosting: Vercel (auto-deploy on push to main)
- AgentOS: myhello/airestate-agentos
- Website: myhello/airestate-website with *.airestate.website wildcard domain
- Custom Domains: Vercel Domains API -- agent adds domain in AgentOS, API adds to Website Vercel project, DNS verification, SSL auto-provisioned
- File Storage: Supabase Storage (V1), images served via Supabase CDN
- Maps: Mapbox GL JS (direct, not react-map-gl) with provider-agnostic adapter (supports Google Maps too)
- CORS: Zero CORS by design -- all DB/search calls go through each app's own API routes