Multi tenancy
Production-grade full-stack SaaS skills for AI coding agents — Next.js, Postgres/Drizzle, Auth, Stripe & Vercel patterns for Codex, Claude Code, Cursor & OpenCode.
npx -y skills add param087/saas-starter-skills --skill multi-tenancyAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when an app has organizations/teams/workspaces — resolve the active tenant from the session or URL, scope every query to it, and guarantee one tenant can never read or write another's data.
SKILL.md
2.9 KB, as published. Nobody here has run it
Multi-Tenancy
Overview
B2B SaaS is multi-tenant: users belong to organizations, and all data is owned by an org. The cardinal rule is isolation — no request may ever touch another tenant's rows. Use the shared-database, shared-schema model (an org_id column on every tenant table) and enforce scoping in one place: resolve the active org from the session, verify membership, and pass orgId into the data-access layer.
When to use
- The app has teams, workspaces, or organizations.
- A user can belong to more than one org and switch between them.
- You're writing any query against tenant-owned data.
Resolving the active org
// src/server/auth/active-org.ts
import "server-only";
import { and, eq } from "drizzle-orm";
import { db } from "@/server/db/client";
import { memberships, organizations } from "@/server/db/schema";
import { requireSession } from "@/server/auth/require";
export async function requireOrg(slug: string) {
const session = await requireSession();
const [row] = await db
.select({ org: organizations, role: memberships.role })
.from(memberships)
.innerJoin(organizations, eq(memberships.orgId, organizations.id))
.where(and(eq(memberships.userId, session.user.id), eq(organizations.slug, slug)))
.limit(1);
if (!row) throw new Error("Not a member of this organization"); // 404/redirect
return { org: row.org, role: row.role, user: session.user };
}
Route by org in the URL — /(app)/[org]/... — and call requireOrg(params.org) in the layout so membership is checked before anything renders.
Isolation strategies (defense in depth)
- App layer (required): every DAL query filters by
org_id(seedata-access-layer). - Database layer (strong): Postgres Row-Level Security with a
current_setting('app.org_id')policy, so even a buggy query can't cross tenants. - Never trust a client-supplied
orgId. Derive it from a verified membership, not a form field or header.
Pitfalls
- Resolving org from a request body/header — a user can forge it. Resolve from session + membership.
- A single missing
org_idfilter — one un-scoped query is a full cross-tenant breach. Centralize in the DAL. - Checking membership in the UI only — re-check on the server for every action and route.
- Global unique constraints that should be per-tenant — e.g. project name unique per org, not globally; use composite unique indexes.
- Leaking tenant existence — return 404, not 403, for orgs the user can't access.
Hand-off
A verified { org, role, user } for the request. Feed role into authorization-rbac and org.id into every data-access-layer call.