Architecture
One app, route groups per product, a proxy that resolves the tenant before your code runs.
One Next.js app. One Supabase project. One Vercel project. As many products as you want to run, each on its own domain.
Two constraints produced that shape. Products are tenants, so you keep one account per vendor rather than one set of accounts per idea. And each shared capability exists in exactly one place, so a fix lands in every product at once. Everything below is how the code arranges itself to keep both true.
The shape
src/
proxy.ts host -> tenant resolution, CSP, security headers
lib/ the chassis: auth, billing, ai, email, content, ...
components/ shared UI kit + shells
app/
layout.tsx root layout: fonts, theme, analytics provider
_products/register.ts the one bootstrap: calls each product's register()
(studio)/ a product = a route group
(cutlist)/
(planer)/
api/ shared routes: one stripe webhook, one cron dispatcher, ...
supabase/
migrations/ core schema + one schema per product
seed.sql dev-only *.localhost host rows
A route group holds only its product's domain logic: pages, components, actions, a register.ts, and its content files. Anything reusable lives in src/lib or src/components. An ESLint boundaries rule enforces this: products cannot import from each other, and the chassis cannot import from a product. Shared code stays shared by lint rule rather than by good intentions, which is what makes one edit to a capability true for every product at once.
Request lifecycle
- A request arrives on a host (
cutlist.millroom.dev, orcutlist.localhost:3100in dev). src/proxy.tslooks the host up incore.domains(cached), resolves{org, product, brand}, stamps them onto trusted headers, attaches the tenant's Content-Security-Policy and the static security headers, and refreshes the session cookie.
The policy carries no nonce on purpose. With partial prerendering, the static shell's script tags are written at build time and cannot hold a per-request value, and adding strict-dynamic makes browsers ignore the self fallback, which blocks every bootstrap script and kills hydration. Injection is defended at the markup layer instead: React escaping, sanitized markdown, and no dangerouslySetInnerHTML of user content. src/lib/security/csp.ts carries the long version.
3. The app renders the product's route group. getTenant() (from @/lib/tenant) reads the resolved tenant back; <BrandStyle> injects the brand's CSS variables server-side, so there is no flash of the wrong brand.
Adding a tenant host is a database row, not a deploy.
Schema per product
Shared cross-cutting tables live in core.*: organizations, members, domains, brands, billing accounts, subscriptions, audit events, AI usage. Each product gets its own Postgres schema (cutlist.*, planer.*) for its domain tables.
Tenancy is org-scoped row-level security. Every product table carries org_id and three policies: member read, role-gated write, and full service-role access. Being signed in grants nothing by itself; access requires membership in an org that belongs to that product, so users of one product never bleed into another.
The registry chassis
Cross-product concerns each have one shared route and a registry that products plug into:
| Concern | Shared surface | A product registers |
|---|---|---|
| Billing | one Stripe webhook route | a ProductBillingHandler |
| Content | markdown loader + accessors | its content directory |
| Sitemap | one sitemap.ts | a paths provider |
| Cron | one /api/cron/[task] route | named tasks |
| Auth | one callback flow | signup hooks |
All registration happens in the product's own register.ts, called once from src/app/_products/register.ts. There are no per-product webhook or cron routes to keep in sync.
One shared route also means one vendor account behind it. The Stripe webhook verifies a signature once and dispatches on metadata.product, so the portfolio needs one Stripe account and one signing secret. /auth/callback carries the originating brand in its own state parameter, so one Google Cloud project and one consent screen cover every product. vercel.json carries every cron entry, whatever the number of products. The count of products goes up; the count of accounts does not.
The two-touchpoint rule
src/lib/products.ts is the one static roster: a manifest per product with its slug, apex origin, and optional per-product AI model tiers and CSP extensions. Adding a product touches exactly two roster points:
- a manifest entry in
src/lib/products.ts - one import and one call in
src/app/_products/register.ts
Everything else inside src/ (types generation, sitemap, synthetic checks, email from-domains, CSP) derives from the roster. If adding a product seems to require editing a third shared file under src/, the chassis is missing a registry; generalize instead of hardcoding. Outside src/ there are three more edits, all mechanical: the TypeScript path alias, the exposed-schema list in supabase/config.toml, and the dev host block in supabase/seed.sql. pnpm new:product performs all five for you; adding a product is the same checklist in prose.
Why not a repo per product
A repo per product means six copies of auth, billing, email and analytics by the time there are six ideas, and the newest copy is always the good one. Every security fix, Stripe API bump, dependency CVE and accessibility pass is then applied six times, or four times and then forgotten twice. One app means one build, one deploy, one set of dependencies, and shared code that is actually shared instead of versioned and drifting. Products stay isolated by route groups, lint boundaries, and database schemas rather than by repository walls. The trade-off is a single blast radius for deploys, which the chassis compensates for with strict typing, the shared test suite, and fail-closed defaults.