What's not included
The honest list. What the kit does not ship, why it was left out, and what to do instead.
Every kit is a set of decisions about what to leave out. This page is the list, written from an audit of the repo rather than from memory, so you can decide before you buy rather than after.
The rule behind most of these: the kit ships the part that is the same in every product, the part you would otherwise be patching once per repo, and stops where it starts being different for each one. A generic version of a product-specific thing is something you delete on day two, and deleting someone else's code is slower than writing your own.
Three things ship as a library with no screen attached
This is the most common shape of "not included" here. The data model, the RLS policies, and the server functions are in the repo and tested. The user interface is not.
| Capability | What ships | What you build |
|---|---|---|
| Platform admin | core.platform_admins, core.is_platform_admin(), isSuperAdmin / requireSuperAdmin from @/lib/auth, and the SUPER_ADMIN_EMAILS bootstrap | The cross-org dashboard itself |
| REST API keys | src/lib/api-keys.ts: hashed-at-rest org-scoped keys, coarse scopes, per-key rate limiting, authenticateApiRequest, core.api_keys | The /api/v1 routes and the key-management screen |
| Tenant-editable pages | src/lib/pages.ts over core.pages: published and draft reads under RLS, upsertPage, reserved terms and privacy slugs | The editor |
Why no admin dashboard. An admin screen is mostly a list of your tables, and every fleet has different tables. A generic one would be wrong for all of them, and a wrong admin screen is the one place a wrong screen is expensive.
What to do instead. For the first year, the Supabase dashboard and the SQL editor are a perfectly good admin surface, and they cost nothing to maintain. When you need a page, put it behind requireSuperAdmin() and write the query. Note that org-level settings do ship: <AccountSettings product> from @/components/account-settings gives every product a profile, team, billing, and domains tab.
AI is text only, and the worked product is not a chat
Planer, the worked product for AI, is a document refiner: paste rough text, pick a profile and a tier, watch it stream back. That was a deliberate choice over the more obvious chat interface.
The reusable part of an AI feature is the order of operations, not the interface. Planer's refine route (src/app/(planer)/api/planer/refine/route.ts) shows it: validate, authenticate, resolve the org, rate limit, refuse early if the gateway is unconfigured, claim the quota slot and the running row in one atomic RPC, then stream. Every AI feature you write needs that spine. A chat transcript, a thread list, tool-call rendering, and retry semantics are product decisions, and shipping one opinionated version of them just gives you something to unpick.
If you want chat, build it on useChat from the AI SDK over a route that keeps the same gate-then-spend order. The AI doc is the walkthrough.
No media AI. No image generation, no speech synthesis, no transcription, no video, no embeddings and no vector search. @/lib/ai wraps text generation through the Vercel AI Gateway and nothing else. The usage ledger is half-prepared for the rest: src/lib/ai-pricing.ts already understands per-unit pricing (per call, per image, per second) alongside per-million-token pricing, and registerModelPrices lets a product add its own rows. So the accounting seam exists; the call sites do not.
Auth: no magic links, no passkeys, no enterprise SSO
What ships is email and password with confirmation, Google OAuth, extra OAuth providers switched on per deploy with NEXT_PUBLIC_AUTH_PROVIDERS (apple, facebook, azure, github), password reset and update, Turnstile CAPTCHA on sign-up, org invites with a role-escalation guard, and TOTP multi-factor.
Three gaps worth naming:
- No magic links or email OTP.
signInWithOtpis close to a one-liner in Supabase, but a link-based login inherits a deliverability posture, a link-expiry UX, and a "you opened this in a different browser" failure mode. Half-shipping that is worse than not shipping it. Adding it: one action plus the existing/auth/callbackroute, which already carries thesafeNextopen-redirect guard. - No passkeys or WebAuthn. The kit's multi-factor support is TOTP only. If passkeys are a requirement, confirm your Supabase version offers the factor type before you plan around it; otherwise this is an identity provider in front of Supabase rather than a patch to the kit.
- No SAML, SCIM, or enterprise SSO. Supabase offers SAML on its paid plans and nothing here wires it up. Selling to individuals and small teams, which is what this kit is shaped for, does not need it; selling to enterprises needs more than any kit gives you.
MFA is a partial. The challenge half ships and works: <VerifyMfaForm>, the per-product /verify-mfa pages, and the canonical server gate enforceMfaStepUp({verifyPath, next}) that forces an AAL2 session before a protected surface (cutlist's dashboard layout is the reference). The enrolment half is library only: enrollTOTP, verifyTOTP, unenrollTOTP and listMFAFactors live in @/lib/auth/mfa, tested, with no screen calling them. You write the enrolment card that renders the QR code and confirms the first code.
Content is markdown files, not a CMS
Blog posts, changelogs, and these docs are .md files with YAML frontmatter, loaded from the repo and rendered through a sanitized <Markdown> component. The page you are reading is docs/whats-not-included.md in the repo you bought.
Not included: a WYSIWYG editor, a media library, scheduled publishing, preview links for non-developers, or an integration with a hosted CMS.
For one person shipping several products, the editor is your editor and the review workflow is a pull request; a CMS is an extra system with its own auth, its own bill, and its own outage. When you genuinely need a non-developer to publish, the seam is small: registerMarkdownContent is one call, and every consumer goes through the cached accessors (getPublishedArticles, getArticleBySlug, getChangelog), so a different loader behind the same accessors is the whole change.
One language, one region, one platform
No internationalisation. Copy is English strings written inline in components. There is no message catalog, no locale routing, no RTL pass, and no translated email templates. Dates render through <LocalDateTime> where the visitor's locale matters, but blog and changelog dates are formatted en-US. Retrofitting i18n is real work, and it touches every product at once; if you need it on day one, budget for it before you buy.
No mobile app. Web only. No React Native, no Expo, no push notifications, no app-store pipeline. The products are responsive, but there is no web app manifest either, so they do not install as a PWA. If you want a native client, the Supabase project and the API-key chassis are what you would point it at, once you have written the /api/v1 routes.
No multi-region. One Vercel project, one Supabase project, one database region. No read replicas, no per-region function pinning, no data-residency story. One database is what makes a new product a schema and a domain row rather than a stack of its own, and multi-region is the first thing that would cost you that.
Background work: the database is the queue
What ships is enough for a lot of real work, and it is worth being precise about the ceiling.
Scheduled work is Vercel Cron. A cron is one registerCronTask(name, {product, run}) call in your product's register.ts plus one entry in vercel.json; the shared dispatcher route handles the CRON_SECRET check, fail-closed refusal, and failure capture. Three tasks ship: synthetic-checks, past-due-downgrade, and audit-retention.
Async job state is a status column plus claimTransition from @/lib/generation-jobs: a conditional UPDATE gated on the set of non-terminal statuses, so exactly one caller wins a transition and the losers no-op. That is what lets a provider webhook, a reconcile cron, and a browser poll race safely over the same row.
What is not there is a queue. No pgmq, no Inngest, no QStash, no worker fleet, no retry-with-backoff scheduler, no fan-out, no priority lanes, no dead-letter handling. The practical limits: cron granularity is a minute at best, and a serverless function has a wall-clock cap (planer's refine route sets maxDuration = 60).
The pattern that gets you a long way without a queue: persist a row before you start the work, return immediately, and let a reconcile cron advance stuck rows. One caveat from .claude/rules/recipes.md that has bitten before: anything a cron retries and that costs money must be bounded, because a permanently failing row bills forever. Persist an attempt counter and stop at a cap. When you outgrow all of this, add a real queue and keep claimTransition as the idempotency guard.
What needs a key before you can exercise it
The vendor accounts are yours. This is source code, not a hosted service. Supabase, Stripe, Resend and PostHog are accounts you open and bills you pay. What the kit changes is the count: one account at each of them carries every product you put on it, rather than one set per idea.
The kit runs with only the two local Supabase keys, and getting started has the full table of what degrades. Two things genuinely cannot be judged without a key, so they are worth calling out here:
- Stripe. Checkout, the customer portal, the webhook, and the scenario suite (
pnpm stripe:scenarios) needSTRIPE_SECRET_KEYset to ansk_test_...key, plusSTRIPE_WEBHOOK_SECRETand the price id thatpnpm stripe:seed-testprints. This is Stripe test mode: no real money, unlimited attempts, and both scripts hard-refuse any key that is notsk_test_. Until then the billing pages render and the upgrade button is disabled. See billing. - A live AI pass. Planer needs
AI_GATEWAY_API_KEY(on Vercel, gateway auth is automatic via OIDC). Without it the refine route returns a friendly not-configured error rather than crashing, and the unit tests still cover the route against a mocked model. A real end-to-end pass, with real tokens in the ledger and real dollars in the cost column, needs the key.
Email needs RESEND_API_KEY to actually send, though locally you do not need it: auth and transactional mail land in Mailpit at http://127.0.0.1:57324. Durable rate limiting needs the Upstash pair, and analytics needs a PostHog key; both fall back to a working no-op.
Operational commitments you inherit
These are not missing features. They are rules the repo enforces, and you are agreeing to live with them.
AI spend has no ceiling, and the support address has to be real
Two things you own the moment you put something public on the internet.
There is no global AI spend cap. Nothing in this repo reads a budget env var or refuses a call because a month got expensive. logUsage measures spend into core.ai_usage_events; nothing reads it back. What bounds a public AI demo is the per-org quota the product enforces for itself (planer: 20 refine passes per org per month, in planer.claim_pass) multiplied by the number of orgs, and an org is created per signup. So SIGNUPS_CLOSED is the spend control. Two sharp edges in the worked example, both deliberate for a demo and both wrong for a business: planer's suggestTitle spends outside the pass quota, and deletePass removes the rows the quota counts, so deleting a pass returns its slot. Copy the claim_pass pattern, not those two.
The support address on your legal pages has to receive mail. src/components/legal/legal-pages.tsx takes supportEmail as a required prop and renders it as a mailto in the privacy policy, the terms, the licence agreement and the refund policy. Mounting those routes publishes a commitment to read that inbox.
Migrations deploy through CI only
.github/workflows/deploy-migrations.yml runs supabase db push on every push to main that touches supabase/migrations/**. That is the single path that mutates production schema. pnpm supabase:push and pnpm db:push are hard-blocked: they print a refusal and exit non-zero.
The reason is that one shared database plus several people (or one person and several agents) pushing DDL from laptops produces a schema nobody can reproduce. One writer and one audit trail is worth the friction.
What you inherit: a repo secret named SUPABASE_DB_URL holding the session-pooler connection string (port 5432, not the 6543 transaction pooler, because migrations need session-level advisory locks), and the habit of shipping schema through a merge rather than a terminal. Plus expand and contract: never drop or rename a column in the same release as the code that stops using it, because Vercel and the database deploy on independent clocks. The one exception is the initial bootstrap of a fresh cloud project, which is a manual supabase db push described in deploying.
The local integration gate
Because the deploy workflow is not gated on CI minutes, the gate is local and enforced by a git hook.
pnpm db:reset && pnpm test:integration
A green run stamps .integration-green with a content hash of supabase/migrations (scripts/migrations-hash.sh). .githooks/pre-push refuses a migration-touching push to main unless the pushed tree's hash matches the stamp, so the stamp cannot vouch for migrations written after the suite ran, and it does not go stale while the migrations are unchanged.
The costs are honest ones: Docker has to be running, and a reset takes as long as your migrations take. SKIP_HOOK=1 git push is the escape hatch, and the consequence is yours: the DDL reaches production ungated. One wiring note, because it surprises people on a fresh clone: pnpm install is what points git at .githooks (the prepare script sets core.hooksPath), so a clone that never ran install has no gate at all.
The seed migration carries the author's data, and it is the first file you edit
supabase/migrations/20260803100700_seed_millroom.sql holds the three resident orgs, their brand token sets, the hosts they answer on (millroom.dev, cutlist.millroom.dev, planer.millroom.dev), and the founding platform-admin email. The file's own header lists the four replacements to make.
It is a migration rather than a fixture on purpose: brands and domains are what the proxy reads to resolve a host to a tenant, so those rows have to exist before the first request in every environment, in a form CI can deploy. Local *.localhost hosts live in supabase/seed.sql instead, which never reaches a cloud project, so local development keeps working whether or not you have edited the seed yet.
Deploy the kit without editing it and your production database will claim to serve millroom.dev.
The short version
You are buying source code: the chassis and three worked products, not a finished application. The parts that are the same for every product are done, tested, and documented. The parts that differ per product, and the screens over the platform-level tables, are yours to write. If a gap on this page is load-bearing for what you are building, it is cheaper to know that now.
Next: running many products for how one set of vendor accounts and one entity carry the whole portfolio, or getting started to run the three products locally.