The Problem with Single-Tenant Thinking
When you start building a SaaS product, it’s tempting to design the database and API around a single customer. Everything is simpler — one schema, one set of tables, one deployment. But the moment you onboard a second customer who needs data isolation, you find yourself retrofitting multi-tenancy into an architecture that was never designed for it.
This post covers the architectural decisions I made while building a multi-tenant POS backend that serves retail locations across multiple organizations.
Approach: Row-Level Tenancy
There are three common patterns for multi-tenancy:
- Separate databases — true isolation, expensive to operate
- Separate schemas — good isolation, complex migrations
- Shared schema with tenant ID — easiest to start, requires disciplined query filtering
For most early-stage SaaS products, row-level tenancy gives you the best balance of simplicity and isolation.
// Every table that holds tenant data includes a tenantId
model Product {
id String @id @default(cuid())
tenantId String
name String
price Decimal
tenant Tenant @relation(fields: [tenantId], references: [id])
@@index([tenantId])
}
Enforcing Tenant Isolation
The danger with row-level tenancy is accidental data leakage if a query forgets the tenantId filter. I addressed this with a repository pattern:
// src/lib/db/repositories/product.repository.ts
export function createProductRepository(tenantId: string) {
return {
findAll: () =>
prisma.product.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
}),
findById: (id: string) =>
prisma.product.findFirst({
where: { id, tenantId }, // tenantId always included
}),
};
}
Every database operation goes through the repository, which always receives the tenant context from the session.
Resolving the Tenant in Next.js App Router
With Next.js App Router, I extract the tenant from the session in the root layout and pass it down via a React context:
// app/[tenantSlug]/layout.tsx
export default async function TenantLayout({ params, children }) {
const tenant = await getTenantBySlug(params.tenantSlug);
if (!tenant) notFound();
return (
<TenantProvider tenant={tenant}>
{children}
</TenantProvider>
);
}
Server Actions then read the tenant from the session rather than from user input — preventing IDOR attacks.
Lessons Learned
- Never trust tenant IDs from client input. Always derive tenant context from the authenticated session.
- Index your
tenantIdcolumns. Query plans degrade quickly without them. - Plan your migration strategy early. Adding a
tenantIdto existing tables without downtime requires careful backfilling. - Soft-delete instead of hard-delete. Tenant data has audit requirements;
deletedAttimestamps are safer.
Multi-tenancy is one of those problems that rewards upfront investment in architecture. Get the patterns right early and scaling to thousands of tenants becomes a database and infrastructure challenge, not a code challenge.