Adding a product
The executable checklist. One command does it, or follow the same steps by hand.
This is where the marginal product gets cheap. A new product is a manifest entry, a register call, and a schema of its own, and it inherits everything the others already have: auth, themed transactional email, billing, analytics, the component kit, the cron dispatcher and the deploy. The legal copy is shared too, brand-parameterised in src/components/legal/legal-pages.tsx, though the two route files are yours to mount. Nothing in the checklist below asks you to open a new Vercel project, a new Supabase project, a new Stripe account or a new signing secret, because the product is a tenant inside the accounts you already keep.
The generator performs the steps below, idempotently, and prints this same checklist as it goes. The manual version exists so you (or an agent) can follow, audit, or repair any step individually. Slugs are lowercase, one word: they become the route group, the schema name, the core.* product value, and the dev host.
Step 1: roster manifest
Add a manifest entry in src/lib/products.ts at the // [millroom:products] anchor:
{ slug: "shipper", apexOrigin: "https://shipper.example.com" },
Optional fields: aiModels: { default: "...", fast: "..." } to pin gateway model tiers, and csp: { connectSrc: [...] } to declare third-party origins the product talks to. See src/lib/product-manifest.ts for the shape.
Step 2: registration
Add one import and one call in src/app/_products/register.ts at the // [millroom:register] anchor:
import { registerShipper } from "@shipper/register";
// inside registerProducts():
registerShipper();
The product's own src/app/(shipper)/register.ts is where everything gets wired: content module, sitemap provider, billing handler, auth hooks, cron tasks. Those two lines are the only shared-file edits inside src/ that a product ever needs. Steps 4 and 5 add three more outside it: the TypeScript path, the exposed-schema list and the dev seed host. That is the entire shared surface a new product touches, and the seventh product added to the portfolio this chassis came out of touched no more of it than that.
Step 3: route group scaffold
Create src/app/(shipper)/ with:
layout.tsxwrapping children in<section data-product="shipper">withgenerateMetadata = () => productMetadata({ titleTemplate: true })error.tsxas a thin<ProductErrorBoundary product="shipper" homeHref="/shipper">wrapperregister.ts(can start empty:export function registerShipper(): void {})shipper/page.tsxfor the landing (the route group nests a literal/shipperdirectory; the proxy rewrites the host root into it)lib/content.tscallingregisterMarkdownContentif the product ships content
The generator copies these from scripts/templates/product/.
Step 4: TypeScript path
Add the product alias to tsconfig.json paths:
"@shipper/*": ["./src/app/(shipper)/*"]
The vitest configs need no edit; they derive product aliases by globbing src/app/(*) at config load.
Step 5: database
- New migration (
pnpm supabase:migration shipper_schema):CREATE SCHEMA shipper;plus PostgREST grants, then your org-scoped tables. Every table:org_id uuid not null references core.organizations(id) on delete cascade, indexed, RLS enabled, with the three standard policies (core.is_org_member/core.current_org_idsread,core.is_org_rolewrite, oneservice_all FOR ALL TO service_role). Copy the shape fromsupabase/migrations/20260803100800_cutlist_schema.sql. - Add
shipperto the[api].schemaslist insupabase/config.toml(PostgREST only exposes listed schemas; forgetting this makes every read of the new schema return null). - Brand and domain rows: a
core.brandsrow for the product's skin and acore.domainsrow for its apex host, in a migration (copy the pattern from20260803100700_seed_millroom.sql). - Append a
shipper.localhostblock tosupabase/seed.sql(dev-only host, mirrors the apex row). Auth redirects need no edit:supabase/config.tomlallowlistshttp://*.localhost:3100/**, so confirmation links come back to the new host. A cloud project needs the real apex added to its own redirect list.
Then apply and regenerate:
pnpm db:reset
pnpm supabase:types # schema list derives from the roster; commit the result
Step 6: verify
pnpm check # lint + typecheck + unit tests
pnpm dev # then open http://shipper.localhost:3100
The new host should render your landing page wearing the new brand row's tokens.
After the scaffold
The generator stops where product decisions start. Typical next moves, each with a recipe in .claude/rules/recipes.md and a prompt in docs/prompts/:
- Auth pages: thin wrappers around the shared
SignInCard/VerifyMfaForm(copy cutlist'ssign-inpages). - A paid tier: entitlements plus a
ProductBillingHandler; see billing. - An AI feature:
getAIClientpluslogUsage; see ai. - Content: markdown files plus
registerMarkdownContentinlib/content.ts. - Cron tasks:
registerCronTaskinregister.tsplus avercel.jsonschedule entry.
Going to production
Four things. Three are dashboard steps that no command in this repo checks for you, and each one fails silently in its own way. Do all four before you tell anyone the product exists.
- Schema. The migration deploys via CI when it lands on
main. Then add the new schema to your cloud project's exposed schemas list (Supabase dashboard, Project settings, API), matching what you added toconfig.toml. Miss it and every read of the schema silently returns null, which reads exactly like a code bug. - Domain.
vercel domains add <apex>, point DNS, and insert the prodcore.domainsrow. Miss it and the host resolves to the wrong tenant or none. - Auth redirect allow list. Supabase dashboard, Authentication, URL configuration, Redirect URLs: add
https://<apex>/**. Miss it and Supabase rewrites the redirect toSite URL, so signing in on the new product drops the user on a different product's site. No error, no log. This is the one that gets missed, including by the people who wrote this document. - Product env vars. Anything the product reads from
process.envthat is not in the chassis schema has to exist in the hosting env, not just in.env.example. A Stripe price id (STRIPE_<PRODUCT>_<PLAN>_PRICE_ID) is the usual one, and its failure mode survives longest: the free tier keeps working and only the upgrade button is dead.
There is no new Vercel project and no new Supabase project in that list. The apex host joins the deploy the other products are already on.
Then smoke it: section 6 of deploying for the 200 and the data-product check, plus one real Google sign-in on the new host in a private window. That sign-in is the only thing that proves step 3.
There is no new Vercel project and no new Supabase project in that list. The apex host joins the deploy the other products are already on.