Back to Blogs

Building Scalable Multi-Tenant Systems with Next.js and Prisma

Muhammad Umar
2 min read

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:

  1. Separate databases — true isolation, expensive to operate
  2. Separate schemas — good isolation, complex migrations
  3. 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 tenantId columns. Query plans degrade quickly without them.
  • Plan your migration strategy early. Adding a tenantId to existing tables without downtime requires careful backfilling.
  • Soft-delete instead of hard-delete. Tenant data has audit requirements; deletedAt timestamps 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.

Back to Blogs