Architecture overview
What it is
A multi-tenant CRM bundled into HREF client engagements. Every client gets their own branded workspace at <slug>.hrefcreative.co. Their team works leads HREF generates via marketing. v1 is deliberately small: lead inbox, pipeline, tasks. v2 hooks (roles, custom fields) are baked into the schema so they drop in without migrations.
The recurring cost target is $0/mo until revenue justifies real infrastructure.
Tech stack
| Layer | Choice | Why |
|---|---|---|
| Frontend + API | Next.js 16 (App Router) on Vercel free tier | Battle-tested, great DX, free for our scale |
| Database + Auth + Realtime | Self-hosted Supabase (OSS Docker stack) | Postgres + GoTrue + Realtime + Storage in one box, no per-row pricing |
| Tenant routing | Subdomains (<slug>.hrefcreative.co) | Clean per-tenant cookies, easy to brand, no parameter wrangling |
| Multi-tenancy isolation | Postgres Row-Level Security (RLS) | Database enforces tenant boundaries; subdomain is UX, RLS is security |
| Resend | Cheap, clean SDK, works as Supabase Auth SMTP | |
| Markdown rendering (docs) | react-markdown + remark-gfm + gray-matter | No build step, edit .md and it ships |
| Validation | Zod | Single schema for API + manual entry |
Sanity is intentionally absent. The CRM has no public marketing surface, branding lives on the tenants row, and editorial content (this docs site) is markdown in the repo.
Big-picture data flow
HREF lead form → POST /api/leads/intake ─→ Next.js route handler
(X-API-Key) (resolves tenant from │
subdomain via proxy.ts) ▼
service-role insert
into leads + lead_activities
(RLS bypassed for this single
trusted server path)
│
▼
┌──────────────┴──────────────┐
▼ ▼
Resend email to Supabase Realtime
every tenant member broadcast on
tenant:<id> channel
Authenticated users in the browser:
acme.hrefcreative.co/crm/... → proxy.ts (auth check) → layout.tsx
(requireMembership)
↓
page.tsx ↔ Supabase JS
(anon key + JWT,
RLS-gated)
Multi-tenancy: subdomain + RLS
Two layers stack:
-
Subdomain is UX scoping. The middleware (
src/proxy.ts) reads thehostheader on every request, extracts the subdomain, looks up the tenant intenants(cached for 60 seconds viaunstable_cache), and stampsx-tenant-sluginto request headers. Pages callresolveTenant()to get the typedTenantContext. -
Row-Level Security is the actual security boundary. Every table with a
tenant_idcolumn has policies of the form:USING ( tenant_id IN ( SELECT tenant_id FROM tenant_members WHERE user_id = auth.uid() ) )The user's JWT goes with every Supabase JS request,
auth.uid()resolves on the database side, and Postgres filters rows it does not have permission to return. A buggy app query asking for someone else's tenant gets zero rows back. Nothing leaks across tenants.
The combination matters. Cookies are scoped per subdomain (Supabase's SSR helper handles this), so a session for acme does not exist on bigfirm. Plus RLS, even if a session were shared, queries would still be filtered. Two doors locked.
Authentication
- Splash on each tenant subdomain. Two ways in:
- Magic link (primary).
signInWithOtpwithshouldCreateUser: false, so unknown emails get a clean error rather than auto-creating accounts. Email goes via Supabase Auth's SMTP, currently configured to send through Resend (crm@hrefcreative.com). - Password (fallback). Set during invite acceptance, used at
signInWithPassword.
- Magic link (primary).
- After sign-in, a tenant-membership check runs server-side. If the email exists in Supabase Auth but has no
tenant_membersrow for this subdomain, we sign them out and bounce them to the splash with?error=not_a_member. - Sign-out is a one-line server action that calls
supabase.auth.signOut()and redirects.
Invite flow
- HREF runs
npm run provision-tenant -- <slug> "<Name>" <admin-email>. The script:- Inserts a
tenantsrow. - Generates a random 32-byte API key (
hcrm_<base64url>), stores its SHA-256 hash, prints the plaintext once. - Calls
seed_default_pipeline_stages(tenant_id)(5 default stages). - Inserts a
tenant_invitesrow with a random 32-byte token, expiring in 7 days. - Prints the invite URL.
- Inserts a
- HREF emails the invite URL to the admin.
- Admin clicks the link. The page validates the token (not expired, not used), shows the email, asks for a password.
- On submit, the server action uses the service-role Supabase client to:
- Create or update
auth.userswith that email + password andemail_confirm: true. - Insert
tenant_members(role: 'admin'). - Mark the invite
accepted_at. - Sign the user in via the SSR client (sets cookies on the response).
- Create or update
- Redirect to
/crm/.
Subsequent invites for the same tenant do not require re-running the provisioning script. Once we ship the Settings → Team tab (Phase 7), tenant admins create their own invites.
File-level architecture
src/
├── app/
│ ├── (public)/ # tenant-subdomain public pages
│ │ ├── page.tsx # branded splash + login form
│ │ └── invite/[token]/ # invite acceptance
│ ├── (auth)/ # tenant-subdomain protected pages
│ │ └── crm/ # inbox, pipeline, tasks, settings, lead detail
│ ├── api/leads/intake/ # public POST endpoint, X-API-Key auth
│ ├── auth/callback/ # magic-link OTP exchange
│ ├── docs/ # public docs site (root domain only)
│ └── layout.tsx # root layout
├── components/ # client components (LoginForm, InviteForm, etc.)
├── lib/
│ ├── auth/
│ │ ├── actions.ts # server actions: sign in, sign out, accept invite
│ │ └── session.ts # requireMembership + maybeRedirectToDashboard
│ ├── supabase/
│ │ ├── client.ts # browser client (anon key)
│ │ ├── server.ts # SSR client (anon key + cookies)
│ │ └── admin.ts # service-role client (bypass RLS, server only)
│ ├── docs.ts # markdown loader for the docs site
│ ├── env.ts # typed env access
│ └── tenant.ts # subdomain → tenant resolver (cached)
├── proxy.ts # tenant resolution + auth gate (Next 16 middleware)
└── ...
docs/ # markdown content for the public docs site
├── user/ # /docs/crm/*
├── api/ # /docs/api/*
├── admin/ # /docs/admin/* (this directory; noindex)
└── plans/ # repo-internal design docs (not rendered)
supabase/
└── migrations/
└── 0001_initial_schema.sql # source of truth for the schema
scripts/
└── provision-tenant.ts # CLI for tenant creation
Hosting today vs tomorrow
| Today (local) | Tomorrow (production) |
|---|---|
Mac Studio runs the Supabase OSS Docker stack at localhost:8000 | Hetzner CX32 + Coolify hosting Supabase at crm-db.hrefcreative.co |
Next.js dev on localhost:3001 | Next.js on Vercel |
*.localtest.me resolves to 127.0.0.1 for tenant subdomain testing | Wildcard DNS *.hrefcreative.co → Vercel |
Schema applied via docker exec ... psql | supabase db push against the Coolify-hosted instance |
The migration is an env-var swap plus a pg_dump | psql of the data. Half a day at most.
Background reading
docs/plans/2026-05-10-href-crm-design.mdis the source-of-truth design document this whole project is built from.- The self-hosted Supabase plan (
/Users/angelom/apps/home/docs/plans/2026-04-30-self-hosted-supabase.md) covers the eventual production hosting story.