Href Creative Docs
HREF Team Docs

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

LayerChoiceWhy
Frontend + APINext.js 16 (App Router) on Vercel free tierBattle-tested, great DX, free for our scale
Database + Auth + RealtimeSelf-hosted Supabase (OSS Docker stack)Postgres + GoTrue + Realtime + Storage in one box, no per-row pricing
Tenant routingSubdomains (<slug>.hrefcreative.co)Clean per-tenant cookies, easy to brand, no parameter wrangling
Multi-tenancy isolationPostgres Row-Level Security (RLS)Database enforces tenant boundaries; subdomain is UX, RLS is security
EmailResendCheap, clean SDK, works as Supabase Auth SMTP
Markdown rendering (docs)react-markdown + remark-gfm + gray-matterNo build step, edit .md and it ships
ValidationZodSingle 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:

  1. Subdomain is UX scoping. The middleware (src/proxy.ts) reads the host header on every request, extracts the subdomain, looks up the tenant in tenants (cached for 60 seconds via unstable_cache), and stamps x-tenant-slug into request headers. Pages call resolveTenant() to get the typed TenantContext.

  2. Row-Level Security is the actual security boundary. Every table with a tenant_id column 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). signInWithOtp with shouldCreateUser: 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.
  • After sign-in, a tenant-membership check runs server-side. If the email exists in Supabase Auth but has no tenant_members row 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

  1. HREF runs npm run provision-tenant -- <slug> "<Name>" <admin-email>. The script:
    • Inserts a tenants row.
    • 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_invites row with a random 32-byte token, expiring in 7 days.
    • Prints the invite URL.
  2. HREF emails the invite URL to the admin.
  3. Admin clicks the link. The page validates the token (not expired, not used), shows the email, asks for a password.
  4. On submit, the server action uses the service-role Supabase client to:
    • Create or update auth.users with that email + password and email_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).
  5. 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:8000Hetzner CX32 + Coolify hosting Supabase at crm-db.hrefcreative.co
Next.js dev on localhost:3001Next.js on Vercel
*.localtest.me resolves to 127.0.0.1 for tenant subdomain testingWildcard DNS *.hrefcreative.co → Vercel
Schema applied via docker exec ... psqlsupabase 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.md is 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.

On this page