| name | SaaS App Structure |
| description | ARCHITECT multi-tenant SaaS applications with data isolation, RBAC, billing abstraction, and auth middleware. Prevent cross-tenant data leaks, unprotected routes, and billing logic scattered across UI. Trigger: "build (a|an) saas", "create (a|an) multi-tenant app", "architect (the) saas backend", "set up subscriptions".
|
| category | full-stack |
| version | 3.0.0 |
| last_updated | 2026-06-28T00:00:00.000Z |
| stacks | ["Next.js 16 (App Router)","React 19.2","Node.js","PostgreSQL 17","Prisma 7 / Drizzle 0.45+","Better Auth / Clerk"] |
| related_skills | ["database-schema-design","backend-validation-layers","production-api-error-handling","api-route-structure","dashboard-information-architecture"] |
SaaS App Structure
IDENTIFY: When to Activate
Activate when building any application with:
- User accounts and authentication
- Paid subscriptions or billing
- Organizations/teams/workspaces (multi-tenancy)
- Role-based access control
DECIDE: Tenancy Model
IF each user is their own tenant →
B2C MODEL: User = Tenant
No organization table needed
user_id scopes all queries
IF organizations/teams/workspaces →
B2B MODEL: Organization = Tenant
REQUIRES: users + organizations + organization_members tables
org_id scopes all queries
EXECUTE: The Four Pillars
Build in this ORDER to avoid rework:
Pillar 1: Authentication (Build First)
2026 RECOMMENDATIONS:
| Scenario | Solution | Setup |
|---|
| Self-hosted, full control | Better Auth | npx better-auth@latest init |
| Existing Auth.js project | Auth.js v5 | npm install next-auth@5 |
| Managed auth, fast setup | Clerk | npm install @clerk/nextjs |
| Enterprise B2B (SSO, SCIM) | WorkOS | npm install @workos-inc/node |
CORE ENDPOINTS (all auth providers implement these):
POST /api/auth/register , Create account
POST /api/auth/login , Sign in (returns session/JWT)
POST /api/auth/logout , Sign out
POST /api/auth/reset-password , Request password reset
POST /api/auth/verify-email , Verify email token
Pillar 2: Multi-Tenancy (Build Second)
CORE TABLES (B2B model):
users
id UUID PRIMARY KEY
email VARCHAR(255) UNIQUE NOT NULL
name VARCHAR(100) NOT NULL
password_hash VARCHAR(255)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
organizations
id UUID PRIMARY KEY
name VARCHAR(100) NOT NULL
slug VARCHAR(50) UNIQUE NOT NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
organization_members
id UUID PRIMARY KEY
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE
role ENUM('owner', 'admin', 'member') NOT NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
UNIQUE(org_id, user_id)
TENANT SCOPING RULE, EVERY data table MUST have org_id:
CREATE TABLE projects (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
);
QUERY PATTERN, ALWAYS scope by org_id:
const projects = await db.project.findMany({
where: { org_id: currentUser.orgId },
});
const projects = await db.project.findMany();
ROW-LEVEL SECURITY (defense-in-depth):
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON projects
FOR ALL
USING (org_id = current_setting('app.current_org_id')::UUID);
await db.$executeRaw`SELECT set_config('app.current_org_id', ${orgId}, true)`;
Pillar 3: Billing (Build After Auth + Core Feature)
BILLING SERVICE, MUST be exactly one file:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const billing = {
async createCheckoutSession(orgId: string, priceId: string) {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/billing/success`,
cancel_url: `${process.env.APP_URL}/billing`,
client_reference_id: orgId,
});
return session.url;
},
async handleWebhook(payload: Buffer, signature: string) {
const event = stripe.webhooks.constructEvent(
payload, signature, process.env.STRIPE_WEBHOOK_SECRET!
);
(event.) {
: (event..); ;
: (event..); ;
}
},
};
WEBHOOK SAFETY RULES:
- ALWAYS verify Stripe signature before processing
- ALWAYS store processed event IDs, reject duplicates
- NEVER update subscription state from the client
- ONLY webhooks update subscription state
Pillar 4: Settings & Management (Build Last)
SETTINGS HIERARCHY:
├── Profile (per user)
│ ├── Name, email, avatar
│ └── Password change
├── Organization (per org)
│ ├── Org name, slug
│ ├── Member management (invite, remove, role change)
│ └── Billing (plan, invoices, payment method)
└── Notifications (per user)
├── Email notifications on/off
└── In-app notification preferences
Auth Middleware: Next.js 16
Next.js 16 uses proxy.ts (replaces middleware.ts in earlier versions):
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';
export default auth((req) => {
const isAuthenticated = !!req.auth;
const isDashboardRoute = req.nextUrl.pathname.startsWith('/app/(dashboard)');
if (isDashboardRoute && !isAuthenticated) {
return NextResponse.redirect(new URL('/login', req.url));
}
});
export const config = {
matcher: ['/app/(dashboard)/:path*'],
};
DEFAULT-DENY PATTERN: Every new route is protected by default. Explicitly mark public routes as exceptions.
Route Architecture
Separate public and authenticated route groups:
app/
├── (marketing)/ # Public, no auth
│ ├── page.tsx # Landing
│ ├── login/page.tsx
│ ├── register/page.tsx
│ └── pricing/page.tsx
├── (dashboard)/ # Auth required
│ ├── layout.tsx # Shared shell
│ ├── dashboard/page.tsx
│ ├── settings/
│ │ ├── profile/page.tsx
│ │ ├── organization/page.tsx
│ │ └── billing/page.tsx
│ └── projects/page.tsx
└── api/
├── auth/[...all]/route.ts
├── webhooks/stripe/route.ts # NO auth, Stripe signs these
└── (dashboard)/
└── projects/route.ts
Implementation Order (Sequential: Do NOT Reorder)
ORDER:
1. Database schema → users, organizations, organization_members
2. Auth → register, login, logout, password reset
3. Organization routing → redirect new users to org creation
4. Auth middleware (proxy.ts) → protect all dashboard routes. Scoped queries.
5. Core feature → first org-scoped feature (e.g., projects)
6. Billing → Stripe integration. Subscription gating.
7. Settings → profile, org, billing management pages
VALIDATE: Quality Gates
OUTPUT: What This Skill Produces
{
"tenancyModel": "b2c | b2b",
"coreTables": [
{ "name": "users", "columns": ["id", "email", "name", "password_hash", "created_at", "updated_at"] },
{ "name": "organizations", "columns": ["id", "name", "slug", "created_at", "updated_at"] },
{ "name": "organization_members", "columns": ["id"
ANTI-PATTERNS: ALWAYS Avoid
| Anti-Pattern | Detection | Correction |
|---|
if (user.role === 'admin') in UI only without backend check | Role check only in client component | Enforce roles in backend middleware/database |
| Billing API keys in frontend code | Public STRIPE_KEY variable | Server-side SDK only, env vars never exposed |
| No tenant scope on queries | db.table.findMany() without WHERE org_id=... | ALWAYS filter by org_id. Use RLS as defense. |
| User registered with no org, empty dashboard | New user has nothing to do | Redirect to org creation immediately after signup |
| Processing webhooks without signature verification | Raw body processed directly | ALWAYS verify Stripe signature first |