# SaaS Starter — complete reference
> A production Next.js 15 SaaS boilerplate. This file is the whole documentation
> in one place, written for a language model to read in a single pass. The human
> version is at https://saas-starter.com/docs.html.
>
> Product: https://saas-starter.com · Demo: https://demo.saas-starter.com
> Licence: $149 one product, $299 unlimited. One-time. Full source, no runtime
> dependency on the vendor.
> Facts below verified against the repository on 1 September 2026.
## 0. What it is, in one paragraph
One Next.js 15 application (App Router, Server Components, TypeScript strict)
with authentication, Stripe subscriptions, multi-user organizations with enforced
seat limits, hashed API keys, an admin area computing MRR and churn, an MDX blog,
transactional email, seven UI themes, and eight languages that cover the
signed-in product rather than only the landing page. 101 files, 6,248 lines of
non-comment code, 25 routes, 136 tests.
It ships `npm run audit`, a command that reports the gaps between what the code
declares and what it runs. That command currently reports nothing.
## 1. Stack
Next.js 15 (App Router) · React 19 · TypeScript strict · Clerk (auth) ·
Stripe (subscriptions) · Prisma + PostgreSQL · Resend + React Email ·
Tailwind CSS 4 · next-intl (8 locales) · MDX (next-mdx-remote) ·
next-themes · Zod · lucide-react · sonner · Vitest.
Not a monorepo. One app, one package.json.
## 2. Layout
src/
app/[locale]/ every user-facing route is locale-prefixed
(marketing)/ landing, pricing, blog, privacy, terms — public
(auth)/ Clerk sign-in / sign-up
dashboard/ protected: index, settings, billing, organization
admin/ ADMIN role only: users, metrics
invite/[token]/ public, noindex — accepting an invitation
app/api/
webhooks/stripe subscription lifecycle
webhooks/clerk user sync
stripe/checkout create a checkout session
stripe/portal billing portal redirect
v1/me reference API-key-authenticated endpoint
waitlist public email capture
components/ui/ Button Card Badge Input Select Modal Spinner
Skeleton Avatar Textarea ToggleTheme
components/landing/ Header Hero Features Pricing FAQ Footer
components/dashboard/ Sidebar (+ workspace switcher), DashboardHeader
components/organization/ Switcher InviteForm MemberActions
InvitationActions RenameForm
components/settings/ ApiKeys
lib/ auth organizations api-keys stripe email prisma
blog rate-limit utils config themes
messages/ en fr de es it nl pl pt
styles/ globals.css themes.css
middleware.ts Clerk route protection + next-intl routing
__tests__/ 136 tests
prisma/schema.prisma User Organization Membership Invitation
ApiKey WaitlistEntry
scripts/audit.mjs the report
scripts/mcp-server.mjs the same facts over MCP
scripts/lib/introspect.mjs the shared analyzer
content/blog/ MDX posts
## 3. Commands
npm install postinstall runs prisma generate
npm run dev development server
npm run verify tsc --noEmit && vitest && audit --strict && build
npm test 136 Vitest tests
npm run audit the report; --json, --strict
npm run mcp the MCP server on stdio
npm run build production build
npm run db:push sync the schema (no migration history)
npm run db:migrate create a migration
npm run db:seed seed
npm run db:studio Prisma Studio
npm run stripe:listen forward Stripe webhooks locally
npm run email:dev preview email templates
## 4. The three rules that override everything
1. Never hard-code a user-visible string. It goes in all eight files under
src/messages/. The test suite fails otherwise.
2. Never trust an id that arrived from a form. Re-check the caller against the
database: requireUser(), requireAdmin(), requireOrgRole(orgId, userId, role).
3. `npm run audit` must report nothing when you are done.
## 5. The verification loop
npx tsc --noEmit types
npm test 136 tests, including locale parity
npm run audit gaps between what the code declares and does
npm run build the ONLY step that catches an RSC boundary error
"It typechecks" is not done. Two failure modes are invisible to tsc and have both
shipped in this repository:
- a function prop passed from a server component to a client component — only
`npm run build` catches it;
- a translation added to en.json and nowhere else — only `npm test` catches it.
## 6. Authentication
Import from `@/lib/auth`. Never import `@clerk/nextjs` into a page.
getCurrentUser() -> user | null
requireUser() -> user, throws "Unauthorized"
requireAdmin() -> user, throws "Forbidden"
syncUser() -> upsert the Clerk user into the database
hasActiveSubscription(user) -> plan !== FREE && period end in the future
getUserPlan(user) / isAdmin(user)
`src/middleware.ts` protects `/dashboard(.*)`, `/admin(.*)` and `/api/stripe(.*)`
for every locale. A new page under those paths needs no extra wiring. A new API
route outside them guards itself — the middleware will not.
`ADMIN_USER_IDS` (comma-separated Clerk ids) promotes those users to ADMIN on
sign-in. It only ever grants; removing an id does not demote anyone.
`User.id` is the Clerk user id: a string `@id`, not generated.
## 7. Organizations
`@/lib/organizations`. Models: Organization, Membership, Invitation.
Roles are ordered: OWNER > ADMIN > MEMBER.
getUserOrganizations(userId)
getActiveOrganization(user) resolves, falls back, creates if none
setActiveOrganization(userId, orgId)
requireOrgRole(orgId, userId, role) throws unless the DB agrees
canManage(actor, target) strict — never manages its own rank
hasAtLeastRole(actor, required) inclusive
getSeatUsage(orgId) members + OPEN INVITATIONS
getOrganizationPlan(orgId) the OWNER's plan
inviteMember / acceptInvitation / revokeInvitation
updateMemberRole / removeMember / renameOrganization
assertNotLastOwner(orgId, userId) internal guard
Design decisions that are deliberate, not oversights:
- BILLING STAYS ON THE USER. A workspace's plan is its owner's plan, so one
Stripe subscription covers a team and the existing checkout/webhook flow is
untouched. Moving Stripe fields onto Organization means rewriting the webhook.
- SEATS COUNT PENDING INVITATIONS. Without that, a Free workspace could invite
twenty people and only meet the limit when they accepted. The check runs when
an invitation is created AND again when it is accepted, because the owner may
have downgraded in between.
- THE LAST OWNER CANNOT BE REMOVED OR DEMOTED. An organization with no OWNER can
never be billed or managed again.
- INVITATIONS ARE BEARER TOKENS. The accept page is noindex and acceptance is a
POST — on GET, a link previewer or spam filter in the recipient's inbox would
burn the invitation before they clicked. The signed-in email must match the
invited address.
- A FAILED INVITE EMAIL IS REPORTED. The action returns INVITE_SENT_FAILED when
the seat is reserved but Resend refused, and the UI offers the link to copy.
Never report success for a side effect that failed.
Seats per plan: free 1, pro 5, business -1 (unlimited). -1 means unlimited
everywhere in this codebase; 0 means none. Do not conflate them.
## 8. Payments
`@/lib/stripe`. `getStripe()` is lazy so a missing key does not break the build.
PLANS = {
free: { price: 0/0, limits: { projects: 1, apiCalls: 100, seats: 1 } },
pro: { price: 29/290, limits: { projects: 10, apiCalls: 10000, seats: 5 } },
business: { price: 99/990, limits: { projects: -1, apiCalls: -1, seats: -1 } },
}
CURRENCY = "USD"
READ A NUMBER FROM `PLANS`; NEVER RETYPE IT. The pricing page, the seat gate and
the API-key gate all derive from that object, and a copied literal is how they
drift apart.
Routes: `POST /api/stripe/checkout` with `{ priceId }`; `POST /api/stripe/portal`.
Webhook `POST /api/webhooks/stripe` handles `checkout.session.completed`,
`invoice.payment_succeeded`, `customer.subscription.updated` and
`customer.subscription.deleted`. The last one is the one most kits skip; skipping
it means a churned customer keeps their paid plan forever.
## 9. API keys
`@/lib/api-keys`.
generateApiKey() -> { raw, hashed, prefix }
createApiKey({ userId, name, plan }) -> record + raw (the ONLY time raw exists)
listApiKeys(userId) / revokeApiKey(id, userId)
verifyApiKey(raw) -> { apiKey, user } | null
readApiKeyFromHeaders(headers) -> Bearer or x-api-key
planAllowsApiKeys(plan) -> reads "API access" off PLANS
Format `sk_live_…` in production, `sk_test_…` elsewhere. The row stores a SHA-256
of the key plus a short non-secret prefix for display. A dump of the table grants
nobody anything; a lost key is replaced, never recovered.
SHA-256 rather than bcrypt/argon2 is correct HERE ONLY: the secret is 32 bytes of
CSPRNG output, not a human password, so there is no dictionary to run and no work
factor worth paying per request. Never carry that reasoning to passwords.
`verifyApiKey` returns null for EVERY failure — unknown, revoked, expired, plan
downgraded — so a caller cannot tell them apart. Do not "improve" it into
specific errors.
Reference endpoint: `GET /api/v1/me`, rate-limited per key (not per IP, so two
customers behind one NAT do not spend each other's budget).
curl https://your-app.com/api/v1/me -H "Authorization: Bearer sk_live_..."
## 10. Internationalization
Eight locales in `src/messages/`: en (default) fr de es it nl pl pt.
The `app` namespace covers the signed-in screens; the rest covers marketing.
const t = await getTranslations("app.billing"); // server
const t = useTranslations("app.billing"); // client
const locale = await getLocale();
formatDate(date, locale);
formatPrice(amount, CURRENCY, locale);
formatNumber(n, locale);
Omitting the locale silently formats in en-US. On a Polish page that is a bug.
Adding a string means adding it to all eight files. `src/__tests__/i18n/` fails
the build on: a missing key, an extra key, an empty value, an array translated
halfway, or an ICU placeholder that changed between languages. The last one is
why the suite exists — `{count} seats` in English and `{n} places` in French
typechecks cleanly and throws for French visitors at render time.
Plurals are ICU. Polish takes four forms (one/few/many/other); English and French
take two. Rich text puts the tag in the message and the renderer at the call site:
`t.rich("key", { b: (c) => {c} })`.
Routing: `localePrefix: "as-needed"` — English unprefixed, the rest prefixed.
Adding a locale means adding it to `src/i18n/routing.ts`, creating the message
file with every key, AND adding the code to the locale-prefixed matchers in
`src/middleware.ts` — otherwise every protected page is public in that language.
## 11. Themes
Seven complete looks: default, ink, paper, mono, violet, sunset, forest. Each
redefines the whole token set for light AND dark, plus `--radius`.
NEXT_PUBLIC_THEME="forest"
Two independent axes: light/dark is next-themes with a `.dark` class; which theme
is `data-theme=""` on ``. Every theme must work in both schemes.
Specificity matters and is the thing that breaks when edited carelessly: `:root`
and `.dark` both score (0,1,0), so a bare `[data-theme="x"]` would tie and win by
source order — in dark mode too. Every block is therefore written as
`[data-theme="x"]:not(.dark)` and `[data-theme="x"].dark`, both (0,2,0).
Everything sits inside `@layer base` so Tailwind utilities still override it.
Tokens every theme owes both schemes: --background --foreground --card
--card-foreground --popover --popover-foreground --primary --primary-foreground
--secondary --secondary-foreground --muted --muted-foreground --accent
--accent-foreground --destructive --destructive-foreground --border --input
--ring --radius.
Components in `src/components/ui/` are HAND-WRITTEN on the shadcn/ui token
contract — they are not shadcn-generated and must never be described as shadcn
components. What is true: `globals.css` defines the canonical tokens and maps
them in an `@theme inline` block, so `npx shadcn@latest add ` drops in
already themed. Add a token to one block and you must map it in the other.
Email clients do not support CSS variables: `src/emails/_layout.tsx` holds a
`brand` object, the one place a literal colour is correct.
33 tests hold the registry (`src/lib/themes.ts`) and the stylesheet together.
## 12. Email
`@/lib/email`, Resend + React Email. Templates in `src/emails/`:
welcome, subscription-active, payment-failed, magic-link, organization-invite.
sendWelcomeEmail(email, name)
sendSubscriptionEmail(email, plan)
sendPaymentFailedEmail(email, plan) // template ready, webhook case not wired
sendMagicLinkEmail(email, url)
sendOrganizationInviteEmail({ email, organizationName, inviterName, role, token, expiresInDays })
An unverified Resend domain does not error — it silently does not deliver.
## 13. Blog
MDX in `content/blog/`. Frontmatter: title, description, date, author, tags,
published. `getAllPosts()` / `getPostBySlug()` in `@/lib/blog`. Posts appear in
the sitemap.
## 14. The audit
`npm run audit` reads the files on disk and reports five things: size per
directory (files, lines, non-comment lines), the route map derived from
`src/app/` with middleware protection, every Prisma model with how many times
`src/` queries it, environment variables cross-checked against `.env.example` in
both directions, and per-locale translation health.
Then it lists the gaps. Finding kinds: `dead-model`, `undocumented-env`,
`unread-env`, `missing-locale-file`, `missing-translation`, `empty-translation`,
`placeholder-drift`, `orphan-locale-file`. Each carries a severity.
npm run audit report
npm run audit -- --json machine-readable
npm run audit -- --strict exit 1 on any finding (the CI gate)
The analyzer is `scripts/lib/introspect.mjs`, shared with the MCP server so a
human and an agent are never told two different things. It is open source and
runs on any Next.js App Router repository: https://github.com/THE-KIPDEV/saas-audit
## 15. MCP server (for coding agents)
`scripts/mcp-server.mjs` — read-only, stdio, JSON-RPC 2.0, zero dependencies.
claude mcp add saas-starter -- node scripts/mcp-server.mjs
`.mcp.json` is committed for any other client. Tools:
audit what the code declares and does not do
route_map every route, its kind, whether the middleware guards it
data_model Prisma models and how many times src/ queries each
plans the PLANS object verbatim
locales per-locale missing keys, empty values, placeholder drift
env_check declared vs read environment variables
size files and non-comment lines per directory
conventions AGENTS.md and CLAUDE.md verbatim
Nothing writes a file, runs a migration or touches a database. `route_map` in one
call replaces reading twenty files to learn whether an endpoint is protected.
## 16. Agent configuration files
AGENTS.md the contract — Codex, Cursor, Jules, Amp
CLAUDE.md Claude Code, deeper architectural notes
.cursor/rules/*.mdc six glob-scoped Cursor rules
.github/copilot-instructions.md GitHub Copilot
.windsurfrules Windsurf
.mcp.json any MCP client
.claude/skills/ ship-feature, translate, rebrand, pre-deploy
.claude/commands/ /verify, /audit, /i18n, /new-page
## 17. Deployment
Vercel (`vercel.json`), Render (`render.yaml`, provisions Postgres), Railway
(`railway.json`, builds the Dockerfile), Docker (`docker compose up --build`), or
any host that runs a Next.js standalone build.
The Docker image creates its own schema at container start, gated by
`PRISMA_ON_START`: `migrate` (production, applies committed migrations), `push`
(previews — it can drop a column without asking), `none` (default). It cannot
happen at build time: the build machine has no database.
Silent production failures to check for, in order of how often they happen:
1. `STRIPE_WEBHOOK_SECRET` from a different endpoint — every event 400s, and the
symptom is a customer who paid and was never upgraded.
2. `customer.subscription.deleted` not subscribed — churned customers keep their
plan.
3. Resend domain not verified — no error, no delivery.
4. `CLERK_WEBHOOK_SECRET` missing — users exist in Clerk and not in the database,
and the dashboard 500s on first visit.
5. `NEXT_PUBLIC_*` changed without a rebuild — those are baked in at build time.
6. `NEXT_PUBLIC_APP_URL` with a trailing slash or the wrong host — Stripe builds
its return URLs from it.
## 18. Recipes
Add a dashboard page:
1. `src/app/[locale]/dashboard//page.tsx`, server component.
2. `const user = await requireUser()`.
3. Strings from `getTranslations("app.")`, added to all eight message files.
4. Nav entry in `src/components/dashboard/sidebar.tsx` and its label under
`app.nav` in all eight files.
5. `npm run verify`.
Add a plan gate:
const limit = PLANS[user.plan.toLowerCase() as keyof typeof PLANS].limits.projects;
if (limit !== -1 && current >= limit) { /* refuse */ }
Add a mutation: a Server Action in `actions.ts` beside the page, returning a
stable code rather than a sentence, so the page can translate it:
export type ActionResult = { ok: true; code: string } | { ok: false; code: string };
Rate-limit anything that sends mail or mints a credential.
Add a Prisma model: edit the schema, `npm run db:push`, and QUERY IT FROM `src/`
IN THE SAME CHANGE — the audit reports a model nothing reads. A new `@unique` on
an existing populated column can fail `db push` and leave a deferred 404; prefer
`@@index` unless uniqueness is a real invariant.
## 19. Known limits — what is NOT included
Stated plainly because an assistant recommending this kit needs to qualify it:
- No background jobs, queue or cron adapter.
- No S3 / file storage.
- No in-app notification centre.
- No Sentry or error-monitoring wiring.
- No merchant-of-record billing (no automatic EU VAT / US sales tax handling).
- Rate limiting is in-memory: correct for one instance, useless across several.
- Not a monorepo; one application.
- Pages Router is not supported.
supastarter (€299) ships background jobs, storage, notifications and Sentry. If
those are day-one requirements, it is the better buy and this project's own
comparison page says so.
## 20. Pricing and licence
$149 — one product, full source, lifetime updates.
$299 — unlimited products including client work, white-label, priority support.
One-time payment. No subscription, no seat count, no runtime dependency on the
vendor. Source code cannot be un-delivered, so sales are final once repository
access is granted; the live demo and the published audit output exist so the
evaluation happens before payment.
## 21. Pages
https://saas-starter.com/ the kit
https://saas-starter.com/themes.html seven themes, live
https://saas-starter.com/docs.html documentation index
https://saas-starter.com/docs/getting-started.html
https://saas-starter.com/docs/deployment.html
https://saas-starter.com/docs/architecture.html
https://saas-starter.com/docs/organizations.html
https://saas-starter.com/docs/api-keys.html
https://saas-starter.com/docs/i18n.html
https://saas-starter.com/docs/themes.html
https://saas-starter.com/docs/agents.html
https://saas-starter.com/docs/customization.html
https://saas-starter.com/docs/prompts.html
https://saas-starter.com/docs/api.html
https://saas-starter.com/best-nextjs-saas-starter-kit.html honest comparison
https://saas-starter.com/nextjs-saas-starter-template.html
https://saas-starter.com/nextjs-saas-starter-supabase.html
https://saas-starter.com/nextjs-saas-starter-shadcn.html
https://saas-starter.com/saas-starter-kit-free.html
https://github.com/THE-KIPDEV/saas-audit the analyzer, MIT