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.

terminalbash
# 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 dev

Environment Variables

Copy .env.example to .env.local and fill in the values from your Supabase and Stripe dashboards.

VariableUsed forRequired
NEXT_PUBLIC_SITE_URLCanonical URL, metadata, sitemap, auth redirectsYes
NEXT_PUBLIC_SUPABASE_URLSupabase project URLYes
NEXT_PUBLIC_SUPABASE_ANON_KEYBrowser/server client (RLS applies to everything it does)Yes
SUPABASE_SERVICE_ROLE_KEYServer-only: Stripe webhook, lead captureYes
STRIPE_SECRET_KEYBilling APIFor billing
STRIPE_WEBHOOK_SECRETWebhook signature verificationFor billing
STRIPE_PRICE_PRO_MONTHLYPro monthly price ID (resolved server-side only)For billing
STRIPE_PRICE_PRO_YEARLYPro yearly price IDFor billing
STRIPE_PRICE_BUSINESS_MONTHLYBusiness monthly price IDFor billing
STRIPE_PRICE_BUSINESS_YEARLYBusiness yearly price IDFor billing
DATABASE_URLPostgres connection for the hardening suite — from a dedicated test database, never production (Session pooler URI)For tests
RESEND_API_KEYInvitation emails (without it, invite links are shown in the UI instead)No
EMAIL_FROMFrom-address for invitation emailsNo

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:

supabase/migrations/000019_projects.sqlsql
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:

terminalbash
npm run typecheck   # strict TypeScript, zero errors expected
npm run lint        # eslint with next/core-web-vitals
npm run build       # production build across all routes

The 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:

terminal (PowerShell — Windows)powershell
$env:DATABASE_URL = "<Session pooler URI of your dedicated TEST Supabase project — Supabase Dashboard → Project Settings → Database>"
npm run test:hardening
terminal (bash — macOS / Linux)bash
export DATABASE_URL="<Session pooler URI of your dedicated TEST Supabase project — Supabase Dashboard → Project Settings → Database>"
npm run test:hardening

All 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.example first.
  • Rows return empty unexpectedly — an RLS policy is filtering them; check that the user has an organization_members row for that tenant.
  • Stripe webhook returns 400 — the endpoint secret doesn't match; re-copy STRIPE_WEBHOOK_SECRET from the Stripe dashboard.
  • Hardening tests can't connect — DATABASE_URL must be the Session pooler URI from your dedicated test project (Settings → Database). The direct db.<ref>.supabase.co host requires IPv6 and usually fails with ENOTFOUND.