Docs
Documentation
B2B SaaS OS is a source-code boilerplate for building multi-tenant B2B SaaS with Next.js, Supabase and Stripe. This page covers the concepts behind the codebase and how to set it up. The package also ships README.md (quick start and environment reference) and HARDENING.md (the adversarial test suite and the four real bugs found in this codebase).
Everything on this page describes the codebase as it ships. Nothing here requires a hosted service beyond your own Supabase and Stripe accounts — and Docker is not required for any workflow below.
Getting Started
The package is a standard Next.js App Router project with strict TypeScript, Tailwind CSS v4 and a component library modeled on shadcn/ui. Marketing, auth and dashboard routes are separated into route groups, and the public /demo page shows the complete dashboard shell with clearly-labeled demo data.
Installation
Requirements: Node.js 20+, a Supabase project and a Stripe account. No global tools, no CLI login required for the app itself.
# 1. Unzip the package
unzip b2b-saas-os.zip && cd b2b-saas-os
# 2. Install dependencies
npm install
# 3. Configure environment (next section)
cp .env.example .env.local
# 4. Push the database schema to your Supabase project
npx supabase login
npx supabase link --project-ref <your-project-ref>
npx supabase db push
# 5. Start the dev server
npm run devEnvironment Variables
Copy .env.example to .env.local and fill in the values from your Supabase and Stripe dashboards.
| Variable | Used for | Required |
|---|---|---|
| NEXT_PUBLIC_SITE_URL | Canonical URL, metadata, sitemap, auth redirects | Yes |
| NEXT_PUBLIC_SUPABASE_URL | Supabase project URL | Yes |
| NEXT_PUBLIC_SUPABASE_ANON_KEY | Browser/server client (RLS applies to everything it does) | Yes |
| SUPABASE_SERVICE_ROLE_KEY | Server-only: Stripe webhook, lead capture | Yes |
| STRIPE_SECRET_KEY | Billing API | For billing |
| STRIPE_WEBHOOK_SECRET | Webhook signature verification | For billing |
| STRIPE_PRICE_PRO_MONTHLY | Pro monthly price ID (resolved server-side only) | For billing |
| STRIPE_PRICE_PRO_YEARLY | Pro yearly price ID | For billing |
| STRIPE_PRICE_BUSINESS_MONTHLY | Business monthly price ID | For billing |
| STRIPE_PRICE_BUSINESS_YEARLY | Business yearly price ID | For billing |
| DATABASE_URL | Postgres connection for the hardening suite — from a dedicated test database, never production (Session pooler URI) | For tests |
| RESEND_API_KEY | Invitation emails (without it, invite links are shown in the UI instead) | No |
| EMAIL_FROM | From-address for invitation emails | No |
Supabase
You connect your own Supabase project. The package ships its schema as readable SQL migrations (organizations, members, invitations, projects, API keys, audit logs, usage, billing) with the RLS policies inline, so you can run supabase db push and audit exactly what gets created. Everything runs against your hosted project — Docker is not required. (If you prefer a local database, npx supabase start works too.)
Authentication
Supabase Auth handles credential storage and sessions. The package supplies the flow around it: sign-in, sign-up, forgot-password and reset-password pages, an auth callback route that exchanges codes for sessions, and OAuth wiring for Google and GitHub.
Organizations
An organization is the tenant root: members, roles, projects, keys, billing state and audit entries all belong to one. The membership table is the join point — a user can belong to many organizations, which is what makes the organization switcher work.
Multi-tenancy
Every tenant-owned row carries an organization_id. There is one database for all customers — isolation is achieved with Row Level Security, not with schema-per-tenant gymnastics. This keeps migrations simple and queries fast.
RLS
Policies are deny-by-default: a table is unreadable unless a policy explicitly allows it, and every policy checks membership through the authenticated user id — never through anything the client sends. The real shape, straight from the migrations:
create policy "Members can view their organization projects"
on public.projects for select to authenticated
using (public.is_organization_member(organization_id));Policies never query the membership table directly — they call the SECURITY DEFINER helpers (is_organization_member, is_organization_admin, is_organization_owner), which prevents recursive policy evaluation and cannot be steered by client input.
RBAC
Three roles — Owner, Admin, Member — enforced in PostgreSQL policies: role changes are owner-only, granting owner through the application is impossible (the invitation role enum cannot even represent it), and members cannot modify organizations or invitations. config/permissions.ts mirrors the same matrix for the in-app hasPermission() helper, so the UI never offers an action the database would reject.
Stripe
The billing layer covers checkout sessions, the customer portal, monthly and yearly subscriptions, and webhook handlers that reconcile Stripe events (subscription updates, invoices, cancellations) into your database. Webhook processing is idempotent (a unique event ledger rejects duplicates), ordering-guarded (stale events never overwrite newer state), and retry-safe (a failed event can be reclaimed by Stripe's automatic retry). You provide the API keys, the four price IDs, and the webhook endpoint URL.
Team Invitations
Invitations are rows with a hashed single-use token, an expiry, and a pre-selected role. Only the invited email address can accept. Acceptance happens at /invite/[token], which validates the token, expiry and email match inside a database function and creates the membership atomically.
API Keys
Keys use a bs_live_ prefix, are stored hashed (SHA-256 — the full value is shown exactly once at creation and cannot be recovered), can carry an optional expiry date, and can be revoked per organization. Validation happens server-side on every request; a revoked or expired key stops working immediately.
Audit Logs
An append-only table records who did what to which resource, with metadata — invite acceptance, role changes, key creation, billing events. Entries are written server-side, never from the browser, and the database requires each entry to be attributed to the acting user.
Usage Limits
API requests through API keys are metered per organization per month and enforced at the plan limit — requests past the limit receive HTTP 429 until the next period or an upgrade. Member counts are enforced at invitation time against the plan's member limit.
Customization
- Branding lives in
config/site.ts— name, tagline, price, URLs. - The color system is CSS variables in
app/globals.css; restyle the whole app by editing one palette. - Navigation is data, not markup:
config/navigation.ts. - Roles and permissions:
config/permissions.ts.
Deployment
Deploy the Next.js app to any Node-compatible host (Vercel, Fly, your own box). Push migrations with the Supabase CLI, then register your deployed /api/stripe/webhook endpoint in the Stripe dashboard. Set all environment variables in the host's dashboard — nothing else is required.
Testing
Quality gates included in the package:
npm run typecheck # strict TypeScript, zero errors expected
npm run lint # eslint with next/core-web-vitals
npm run build # production build across all routesThe adversarial hardening suite runs against a real Postgres — a dedicated, disposable test database (a separate Supabase project you can freely reset), with the actual migrations and RLS policies — not mocks. Never point the suite at a production database:
$env:DATABASE_URL = "<Session pooler URI of your dedicated TEST Supabase project — Supabase Dashboard → Project Settings → Database>"
npm run test:hardeningexport DATABASE_URL="<Session pooler URI of your dedicated TEST Supabase project — Supabase Dashboard → Project Settings → Database>"
npm run test:hardeningAll nine suites are expected to pass (9/9 test files, as documented in README.md). See HARDENING.md for what each one attacks.
Troubleshooting
- Build fails on missing env — you skipped
.env.local. Copy.env.examplefirst. - Rows return empty unexpectedly — an RLS policy is filtering them; check that the user has an
organization_membersrow for that tenant. - Stripe webhook returns 400 — the endpoint secret doesn't match; re-copy
STRIPE_WEBHOOK_SECRETfrom the Stripe dashboard. - Hardening tests can't connect —
DATABASE_URLmust be the Session pooler URI from your dedicated test project (Settings → Database). The directdb.<ref>.supabase.cohost requires IPv6 and usually fails withENOTFOUND.