Running many products
The business half of the same idea. One account at each vendor, one LLC, one bank account, carrying a whole portfolio.
Six ideas in flight is six Vercel projects, six Supabase projects, six Stripe accounts and six PostHog projects, and every one of them is due before the idea it belongs to has a single user. This page is the business half of the answer: how the accounts, the entity and the books are arranged so that adding the sixth product is a row rather than a project.
The claim is narrow. What multiplies with the number of ideas is vendor accounts and copies of the same code, and both of those can be held at one. The business should be the same shape as the codebase. One legal entity with several trade names is src/lib/products.ts. One Stripe account keyed on metadata.product is the webhook router. One Postgres with a schema per product is, literally, one Postgres with a schema per product. When the two shapes match, there is exactly one place to look for anything.
This is lived experience, not advice. I am not a lawyer, a CPA, or an accountant, and nothing on this page is legal, tax, or accounting advice. Entity choice, DBA registration, tax treatment, and what counts as adequate separation all vary by state, by country, and by your circumstances. What works for one LLC in one state can be exactly wrong for you. Talk to a CPA and an attorney before you copy any of it. The vendor prices below are what I paid on the dates given; check the current ones yourself.
The two shapes
| In the business | In the codebase |
|---|---|
| One LLC | One repo, one deploy |
| A DBA per public product | A manifest entry in src/lib/products.ts |
| One Stripe account, products tagged in metadata | One webhook route, dispatch on metadata.product |
| One Google Cloud project, several authorized domains | One /auth/callback, the brand carried in state |
| One Postgres, a schema per product | core.* shared, cutlist.* and planer.* private |
| One Resend account, several verified sending domains | EMAIL_VERIFIED_FROM_DOMAINS |
| One PostHog project | Every event stamped with product |
| One Vercel project | One repo, one deploy, every cron entry in one vercel.json |
| One Stripe webhook endpoint, one signing secret | src/app/api/webhooks/stripe/route.ts, one STRIPE_WEBHOOK_SECRET |
| One sitemap and one robots route | src/app/sitemap.ts, resolved through core.domains, and src/app/robots.ts |
| One set of legal copy, themed per brand | src/components/legal/legal-pages.tsx |
| One bank account, a virtual card per vendor | One .env, one bill to read |
Read that table in either direction. Everything below is the long version of a row.
The other thing that multiplies
The rest of this page is the accounts, the entity and the books. The other thing that multiplies is code: six repos means six copies of auth, billing, email and analytics, and every security fix, Stripe API bump and dependency upgrade gets applied six times, or four times and then forgotten twice. The kit's answer is that each shared capability exists in exactly one place, so you patch one file and every product has it. That half is architecture.
One entity, several names
Everything I ship belongs to Hamma Ridge Ventures, LLC. One entity, one bank account, one tax return, covering the whole portfolio. Each product that needs a public identity gets a DBA (a fictitious business name, an assumed name, a trade name: the label depends on your state) rather than its own company.
A DBA is a name registration, not an entity. There is no new EIN, no new operating agreement, no separate return, no second registered agent, no second annual report. Where you file it varies: some states register trade names centrally, some push it down to the county, and a few want a newspaper notice. The filing fee is trivial compared to the recurring cost of a second entity, which is the actual expense you are avoiding.
The thing to understand before you like this too much: a DBA gives you a separate name, not separate liability. Every DBA is the same LLC. If a customer sues the product, they are suing the entity and everything it owns, including every other product's revenue.
When a product has genuinely earned its own entity. Three signals, and any one of them is enough:
- Real liability exposure that is different in kind from the rest. Handling money, health data, minors, physical goods, or regulated advice puts risk on one product that the others should not be standing next to.
- Outside money. An investor is buying shares in a company, not a line item inside your holding pot. This is also the point where the accounting stops being casual.
- A sale you can actually see coming. Buyers want an asset they can take delivery of. Untangling a product out of a shared entity, a shared Stripe account, and a shared database is work you do under time pressure at the worst possible moment.
Until one of those is true, a second entity is a second registered agent, a second annual report, a second bank account to reconcile, and a second tax return, in exchange for a boundary nobody is using yet.
I formed through Stripe Atlas. At the time of writing it is $500 one time, covering Delaware formation and the state filing fees, the tax ID, founder equity paperwork, the 83(b) filing, and the first year of registered agent service, then $100 a year to keep the agent. Atlas forms C corps, LLCs, and subsidiaries. Being honest about what I actually used: the equity issuance and 83(b) machinery is C corp furniture and irrelevant to the LLC I actually formed. What I got for the money was the filing, the EIN, and a registered agent, without spending a week reading state forms. If you enjoy state forms, you can do all of it yourself for less.
One Stripe account
One account, one set of payout details, one dispute history, one place to look when someone asks what happened to a charge. Products are separated by metadata, not by account.
The kit implements exactly that. createProductCheckout (src/lib/billing/checkout.ts) stamps { product, org_id } onto the Stripe customer, the checkout session, and the object the router will read off the event later: the PaymentIntent for a one-time charge, the Subscription for a recurring one. The single webhook route at src/app/api/webhooks/stripe/route.ts verifies the signature once, claims the event id so a redelivery cannot double-apply, resolves the product through resolveProductForEvent, and dispatches to whichever ProductBillingHandler registered for that slug.
That resolver is where the honest engineering lives. Some events carry your metadata and some do not: invoice.paid and invoice.payment_failed arrive with nothing product-shaped on them. So it prefers metadata.product and falls back to the Stripe customer id, looked up against core.billing_accounts, whose product column holds the same slug. Two paths to the same answer, and an event that resolves to neither is ignored rather than guessed at. See billing for the working version.
The practical payoff is that adding a product to billing is a handler registration in that product's register.ts. There is no new webhook endpoint to configure in the Stripe dashboard, no new signing secret in the environment, and no fifth place where a subscription status check might be written slightly differently.
The practical cost is that one Stripe account is one risk profile. A product with a bad chargeback rate is a problem for all of them.
One Google Cloud project
Google OAuth is where the per-product instinct hurts most, because the expensive part is not the client, it is the review.
Google wants every top private domain associated with your consent screen verified, and verification means proving ownership in Search Console from an account that has owner permission on the domain and is attached to that Cloud project. Do that once for a project carrying several authorized domains and you are finished. Do it per product and you are repeating consent screen configuration, branding review, domain verification, and (if you ever request a sensitive scope) a scope justification, once per product, forever. Check the current authorized-domain limit for one consent screen before you plan on carrying twenty products through it.
So: one Cloud project, one consent screen, one OAuth client, several authorized domains, and one callback URL that every brand shares. Which creates the actual engineering problem, because a shared callback loses track of which brand the user came from. The kit's answer is a central callback with the origin carried in state:
signInWithGoogle(src/lib/auth/actions.ts) encodes{ brandHost, next }into anmr_stateparameter appended toredirectTo, pointing at/auth/callbackon the current origin.src/app/auth/callback/route.tsdecodesmr_state, exchanges the code for a session, infers the product from the first path segment of the sanitizednextviaisProductSlug, runs that product's first-sign-in hooks (welcome email, attribution, MFA step-up), and redirects to the forwarded host so the session cookie lands on the brand's own domain./auth/callbacksits in the proxy's excluded-path list (src/proxy.ts), so it resolves the same way no matter which tenant the request arrived on.
Two things I got wrong and you should not. First, do not put your brand state in the OAuth state parameter: Supabase PKCE owns it, and overriding it fails the exchange with bad_oauth_state. That is why the kit uses mr_state. Second, the post-sign-in destination arrives from a place an attacker can influence, so it goes through safeNext before it is concatenated onto anything.
Supabase has its own version of the same gotcha. Every product apex has to be listed under Authentication, URL configuration as https://yourproduct.com/**, because Supabase silently rewrites a redirect URL it does not recognise back to the project's Site URL. Miss one and that product's confirmation emails deliver users to a different product. Locally, supabase/config.toml allowlists http://*.localhost:3100/**, which covers every product you add later.
One Supabase project, a schema per product
One project is one set of keys, one auth URL allowlist, one migration history, one dashboard to open when something looks wrong. I was running four separate Supabase projects, one per product, because that is the default path and each one takes ten seconds to create. On 2026-07-03 I consolidated them into a single project with a schema per product.
Four projects had quietly become four of everything around them: four sets of keys across four env files, four redirect allowlists to keep in step, four migration histories, and four places a schema change could be half-applied. None of that is visible on the day you create the second project, which is what makes it worth saying. The bill moved too, and the numbers are further down, under what the whole thing costs.
Inside the one project, separation is by schema. core.* holds what is genuinely shared (organizations, members, domains, brands, billing accounts, subscriptions, audit events, AI usage). Each product gets its own schema for its own tables. Tenancy is org-scoped row-level security built on shared helpers, core.is_org_member, core.is_org_role, core.current_org_ids, so that being signed in grants nothing by itself and users of one product never bleed into another. Architecture has the details, and deploying covers the one cloud setting people miss (the exposed-schemas list, which is separate from config.toml).
The alternative I rejected. I priced moving to Neon, which is cheaper Postgres. I did not do it, and the reason is worth stating plainly because it is the sort of thing a spreadsheet will not tell you: my apps do not use Postgres, they use Supabase. Auth, the auth.uid() calls that every RLS policy is built on, PostgREST as the data API, storage, the client libraries. Swapping the database engine would have meant replacing the authentication system and rewriting every policy, to save an amount per month that the migration would not have earned back for years. Cheaper Postgres is only a drop-in replacement if what you were using was Postgres.
One Resend account
One account, several verified sending domains. Set EMAIL_VERIFIED_FROM_DOMAINS to a comma-separated list, best-generic-first, and the sender resolves an address per message: the site host if it is verified, otherwise a verified subdomain of it (Resend verification often lands on mail.<apex>), otherwise the product's apex, otherwise the first entry in the list. Unset, it falls back to the product apexes from the roster.
That fallback chain exists because Resend hard-rejects an unverified domain with a 403 and a malformed host like localhost:3000 with a 422, and an email that throws in a webhook handler is a bug you find late. The display name stays the tenant's brand in every case; only the address domain degrades.
Templates are shared and themed per tenant. One <EmailLayout> and a set of primitives read the brand's tokens out of core.brands, so a new product's transactional email looks like that product without anyone forking a template. sendEmail is the only transport, which means there is one place that decides whether mail is sent, logged, or suppressed in development.
One PostHog project
One project, every event carrying a product property. captureServerEvent stamps it server-side; the client provider registers it globally so browser events match; funnel events add the org group. Fleet-wide questions ("which product converts signups best") and per-product questions are then the same query with a different filter, on one dashboard, at one subscription.
AI spend rides the same rail and matters more than it sounds. logUsage is the single chokepoint: it writes a row to core.ai_usage_events (product, model, tokens, cost_usd, success, and the error when the call failed) and emits the $ai_generation span that PostHog's LLM analytics reads. One emitter, so nothing double-counts, and one table to sum when you want to know what the portfolio spent on inference last month. AI covers the client side.
There is an escape hatch for the day a product needs its own project, which is usually the day you sell it or hand it to a collaborator: set POSTHOG_KEY_<PRODUCT> and that product's events route elsewhere with no code change.
Banking and books
Mercury for business checking, and it has been excellent. No monthly fee on the account I use, no minimum, and the interface is built for people who look at their money in a browser. Worth knowing what it is: Mercury is a financial technology company rather than a bank, with deposits held at partner banks.
Two habits do most of the work.
A virtual card per vendor. Mercury issues virtual cards on demand from the dashboard, each with its own number. Vercel gets one, Supabase gets one, Resend gets one. When a charge shows up you already know what it is without opening a statement, and when a trial quietly converts into a subscription you can kill exactly that card without reissuing anything else. This is the cheapest possible protection against the failure mode that actually gets you when you are running several products at once, which is not one big expense but a long tail of small ones nobody remembers signing up for.
Categories instead of accounting software, until the volume earns it. Mercury's transaction categories plus GL codes are enough for one cash-basis LLC doing roughly 350 transactions a year. I ran a $25 a month Xero subscription alongside it for a while and cancelled it in June 2026, because it was re-presenting information I already had in a form I was not reading. That is a judgement about scale, not a criticism of Xero: at payroll, inventory, accrual accounting, or a bookkeeper who needs an interface, the answer flips immediately. Ask your CPA where your line is rather than copying mine.
What the whole thing costs
This section is late on purpose. Nobody's portfolio is failing over $35 a month, and if the bill were the argument, the argument would be weak. The line items are here because a page like this is worthless if the numbers are decorative:
| Item | What changed |
|---|---|
| Supabase | 4 projects at $59.74/mo, consolidated 2026-07-03 into 1 project at $25/mo. Pro kept deliberately for daily backups. |
| Bookkeeping | Xero at $25/mo, cancelled June 2026. Mercury categories plus GL codes cover it at this volume. |
| Banking | Mercury business checking, no monthly fee on my account. |
| Formation | Stripe Atlas, $500 one time, then $100/yr for the registered agent. |
| Hosting | One Vercel Pro team carrying every product, instead of a team per product. |
| Entity overhead | One LLC, one bank account, one tax return, however many DBAs. |
The Supabase line is the one worth understanding, because it is not a discount, it is a structural difference. Pro is $25 a month and includes a $10 monthly compute credit that covers one Micro instance. One project, credit applied, $25. Every additional project brings its own compute charge and the credit covers only the first, so the bill was scaling with the number of projects, a number I chose casually, rather than with traffic or data, which are the numbers that should drive it. I stayed on Pro rather than dropping to Free deliberately: Pro carries daily backups, and there is real customer data in there.
The pattern in that table is not frugality. Every single line is the same move: something that defaults to one-per-product became one-for-the-portfolio. The savings are a side effect of there being fewer things, and they are the least interesting thing on this page. What the consolidation actually bought was fewer accounts to open and fewer copies of the same code to keep patched.
When this pattern stops working
It does stop. Here is where, honestly.
One database is one blast radius. A bad migration, a runaway query, or a paused project takes down every product at once. The kit fights this with CI-only migration deploys, a local integration gate that must be green before a migration-touching push to main, and an expand/contract rule that forbids dropping a column in the same release as the code that stopped using it. Those reduce the odds. They do not change the radius.
One deploy is one blast radius too. A type error that reaches production breaks all tenants together, which is why the build refuses to suppress type and lint errors.
One Vercel team is one permission set. Anyone you add so they can ship a fix to one product can see and deploy all of them. There is no polite way to give a contractor access to product three only.
One entity is one liability pool. Worth repeating because DBAs make it easy to forget: a claim against one trade name reaches everything the LLC owns.
One Stripe account is one merchant reputation. Payouts, disputes, and risk review are account-level.
Compliance is where you should leave. If a product needs SOC 2, HIPAA, or anything with an auditor attached, the audit scope is the system boundary, and sharing a database with five unrelated products makes the boundary your entire portfolio. Pulling that product out into its own project, its own entity, and its own account is cheaper than certifying everything else by accident.
Selling is the other place you should leave. A buyer wants a clean asset. This is one reason the kit gives each product its own Postgres schema for its domain tables, rather than pooling everything into shared tables with a product column: extracting one product is closer to dumping one schema than to filtering every table in the database. It is still work. It is just work you can describe.
What I would do differently
Start consolidated. I did not, and the reason is embarrassing and probably yours too: creating a new project per idea is the default in every dashboard, it takes ten seconds, and no single instance of it feels like a decision. Do it six times and you have six accounts nobody ever decided to open.
The general form of the lesson: every vendor's onboarding flow is designed around one company shipping one product, so the default is always per-product, and the default is always wrong for a portfolio. When you sign up for anything new, the first question is what the account, project, or workspace boundary is, and whether one of them can carry all of it. Usually one can. The exceptions in my stack, so far, are none.
Next: architecture for how the codebase enforces the same shape, and deploying for pointing real domains at tenants.