Use when you have a written implementation plan to execute in a separate session with review checkpoints
npx skills add soilmass/vibe-coding-plugin --skill "multi-tenancy"
Install specific skill from multi-skill repository
# Description
>
# SKILL.md
name: multi-tenancy
description: >
Multi-tenant architecture — row-level isolation, subdomain routing, Prisma Client Extension, tenant context propagation
allowed-tools: Read, Grep, Glob
Multi-Tenancy
Purpose
Multi-tenant architecture for Next.js 15 SaaS applications. Covers row-level isolation with
Prisma Client Extensions, subdomain-based tenant routing, tenant context propagation through
Server Components and Actions, and cross-tenant data prevention.
When to Use
- Building a B2B SaaS with organization/workspace isolation
- Implementing subdomain-based tenant routing (
acme.app.com) - Auto-injecting tenant filters on every Prisma query
- Setting up tenant onboarding flows (create org → invite → seed)
- Preventing cross-tenant data leaks
When NOT to Use
- Single-tenant application → no tenancy needed
- User-level permissions within a tenant →
auth - Feature toggling per tenant →
feature-flags - API key management →
security
Pattern
Tenant Prisma model
model Tenant {
id String @id @default(cuid())
name String
slug String @unique // Used for subdomain: slug.app.com
plan String @default("free")
createdAt DateTime @default(now())
users TenantUser[]
projects Project[]
}
model TenantUser {
id String @id @default(cuid())
tenantId String
userId String
role String @default("member") // "owner" | "admin" | "member"
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([tenantId, userId])
@@index([userId])
}
model Project {
id String @id @default(cuid())
tenantId String
name String
// ... other fields
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
}
Prisma Client Extension for tenant isolation
// src/lib/db-tenant.ts
import "server-only";
import { PrismaClient, Prisma } from "@prisma/client";
const basePrisma = new PrismaClient();
export function getTenantDb(tenantId: string) {
return basePrisma.$extends({
query: {
$allModels: {
async findMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async findFirst({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async create({ args, query }) {
args.data = { ...args.data, tenantId } as typeof args.data;
return query(args);
},
async update({ args, query }) {
args.where = { ...args.where, tenantId } as typeof args.where;
return query(args);
},
async delete({ args, query }) {
args.where = { ...args.where, tenantId } as typeof args.where;
return query(args);
},
async deleteMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async updateMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
},
},
});
}
Tenant resolution middleware
// src/middleware.ts
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const hostname = request.headers.get("host") ?? "";
const subdomain = hostname.split(".")[0];
// Skip for main domain and API routes
if (subdomain === "www" || subdomain === "app" || hostname === "localhost:3000") {
return NextResponse.next();
}
const response = NextResponse.next();
response.headers.set("x-tenant-slug", subdomain);
return response;
}
Tenant context in Server Components
// src/lib/tenant.ts
import "server-only";
import { headers } from "next/headers";
import { cache } from "react";
import { db } from "@/lib/db";
export const getTenant = cache(async () => {
const headerList = await headers();
const slug = headerList.get("x-tenant-slug");
if (!slug) return null;
return db.tenant.findUnique({ where: { slug } });
});
export const requireTenant = cache(async () => {
const tenant = await getTenant();
if (!tenant) throw new Error("Tenant not found");
return tenant;
});
Tenant-scoped Server Action
// src/actions/projects.ts
"use server";
import { auth } from "@/lib/auth";
import { requireTenant } from "@/lib/tenant";
import { getTenantDb } from "@/lib/db-tenant";
import { z } from "zod";
const CreateProjectSchema = z.object({
name: z.string().min(1).max(100),
});
export async function createProject(prevState: unknown, formData: FormData) {
const session = await auth();
if (!session?.user?.id) return { error: "Unauthorized" };
const tenant = await requireTenant();
const tenantDb = getTenantDb(tenant.id);
const parsed = CreateProjectSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: "Invalid input" };
await tenantDb.project.create({
data: { name: parsed.data.name },
// tenantId auto-injected by extension
});
return { success: true };
}
Tenant onboarding flow
// src/actions/onboarding.ts
"use server";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";
export async function createTenant(prevState: unknown, formData: FormData) {
const session = await auth();
if (!session?.user?.id) return { error: "Unauthorized" };
const name = formData.get("name") as string;
const slug = formData.get("slug") as string;
const tenant = await db.$transaction(async (tx) => {
const newTenant = await tx.tenant.create({
data: { name, slug },
});
await tx.tenantUser.create({
data: {
tenantId: newTenant.id,
userId: session.user!.id,
role: "owner",
},
});
return newTenant;
});
return { success: true, tenantId: tenant.id };
}
Cross-tenant cache isolation
// Always namespace cache keys with tenant ID
import { unstable_cache } from "next/cache";
import { requireTenant } from "@/lib/tenant";
export async function getTenantProjects() {
const tenant = await requireTenant();
return unstable_cache(
async () => {
const tenantDb = getTenantDb(tenant.id);
return tenantDb.project.findMany();
},
[`projects-${tenant.id}`],
{ tags: [`tenant-${tenant.id}-projects`] }
)();
}
Anti-pattern
Queries without tenant filter
Every Prisma query on tenant-scoped data MUST include tenantId. A single unscoped
query is a data leak vulnerability. Use Prisma Client Extensions to auto-inject the filter.
Trusting client-sent tenant ID
Never accept tenantId from request body or query params. Resolve it from the
subdomain in middleware or from the authenticated session. Client-sent IDs enable
tenant impersonation.
Common Mistakes
- Missing
tenantIdindex on scoped tables — slow queries - Not using
$transactionfor tenant onboarding — partial state on failure - Cache keys without tenant namespace — cross-tenant cache pollution
- Forgetting to scope
deleteMany/updateMany— bulk operations bypass per-record filters if not handled in the Client Extension; always add explicit handlers for these methods - File uploads without tenant directory — shared storage leaks data
Checklist
- [ ] Tenant model with slug for subdomain routing
- [ ] TenantUser join table with roles
- [ ] All scoped tables have
tenantIdwith@@index - [ ] Prisma Client Extension auto-injects tenant filter
- [ ] Middleware resolves tenant from subdomain
- [ ]
getTenant()/requireTenant()cached withReact.cache() - [ ] Server Actions validate tenant context
- [ ] Cache keys namespaced by tenant ID
- [ ] Tenant onboarding uses
$transaction
Composes With
prisma— tenant schema design and Client Extensionsnextjs-middleware— subdomain tenant resolutionauth— user-tenant role verificationsecurity— cross-tenant data leak preventionfeature-flags— per-tenant feature configuration
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.