Back to Blog

Multi-tenant SaaS architecture: building scalable SaaS with Next.js

Architecture
Multi-tenancy
Scalability
Next.js
TypeScript
Database
Security

Multi-tenant SaaS architecture is the foundation of scalable software-as-a-service applications. Whether you're building a B2B SaaS serving hundreds of companies or a platform supporting thousands of users, understanding multi-tenancy is crucial for creating efficient, secure, and maintainable applications. This comprehensive guide will walk you through implementing multi-tenant architecture in Next.js, covering database strategies, security patterns, and performance optimization.

What is multi-tenancy?

Multi-tenancy is an architecture pattern where a single instance of your application serves multiple customers (tenants). Each tenant's data is isolated and remains invisible to other tenants, even though they share the same infrastructure, database, and application code.

Think of it like an apartment building: multiple tenants live in the same building (shared infrastructure), but each has their own apartment (isolated data) with locks on the doors (security). In SaaS terms, this means:

  • Shared infrastructure: One application instance serves all tenants
  • Data isolation: Each tenant can only access their own data
  • Cost efficiency: Resources are shared, reducing infrastructure costs
  • Easier maintenance: One codebase to maintain and deploy

Multi-tenant architecture patterns

There are three primary approaches to multi-tenant architecture, each with different trade-offs:

1. Database per tenant

Each tenant has their own dedicated database. This provides the strongest isolation but requires more resources and complexity.

Strongest data isolation
Easy to scale individual tenants
Simpler backup and restore per tenant
Higher infrastructure costs
Complex connection pooling
Difficult cross-tenant analytics
Best for: Enterprise SaaS with strict compliance requirements

2. Shared database, separate schemas

All tenants share the same database, but each has their own schema (namespace). This balances isolation with resource efficiency.

Good data isolation
Efficient resource usage
Easier cross-tenant operations
Schema management complexity
Migration complexity across schemas
Best for: Mid-market SaaS with moderate isolation needs

3. Shared database, row-level security

All tenants share the same database and schema, with a tenantId column used to filter data. This is the most resource-efficient approach.

Most cost-effective
Simplest to implement
Easy cross-tenant analytics
Simpler migrations
Requires careful security implementation
Risk of data leakage if not properly secured
Best for: Most SaaS applications (recommended starting point)

Implementing row-level security in Next.js

For most SaaS applications, the shared database with row-level security approach offers the best balance of simplicity, cost, and scalability. Let's implement this pattern in Next.js with Prisma.

Step 1: Add tenant context to your schema

First, extend your Prisma schema to include a tenantId field in all tenant-scoped models:

packages/database/prisma/schema.prisma
model Tenant {
  id        String   @id @default(cuid())
  name      String
  subdomain String?  @unique // Optional: for subdomain-based routing
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  
  users User[]
  projects Project[]
}

model User {
  id        String   @id @default(cuid())
  email     String
  name      String?
  tenantId  String   // Tenant association
  role      String   @default("member") // "owner", "admin", "member"
  
  tenant    Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  projects  Project[]
  
  @@unique([email, tenantId]) // Email unique per tenant
  @@index([tenantId])
}

model Project {
  id          String   @id @default(cuid())
  name        String
  description String?
  tenantId    String   // Tenant association
  
  tenant      Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  owner       User     @relation(fields: [ownerId], references: [id])
  ownerId     String
  
  @@index([tenantId])
  @@index([ownerId])
}

Step 2: Create tenant context middleware

Create a middleware to extract and validate the tenant from the request. This can be done via subdomain, custom header, or JWT token:

apps/fastack-boilerplate/src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
    // Extract tenant from subdomain
    const hostname = request.headers.get('host') || '';
    const subdomain = hostname.split('.')[0];
    
    // Or extract from custom header
    const tenantId = request.headers.get('x-tenant-id');
    
    // Or extract from JWT token (if using NextAuth)
    // const session = await getServerSession();
    // const tenantId = session?.user?.tenantId;
    
    // Add tenant to request headers for use in server components
    const requestHeaders = new Headers(request.headers);
    if (tenantId) {
        requestHeaders.set('x-tenant-id', tenantId);
    }
    
    return NextResponse.next({
        request: {
            headers: requestHeaders,
        },
    });
}

export const config = {
    matcher: [
        '/((?!api|_next/static|_next/image|favicon.ico).*)',
    ],
};

Step 3: Create tenant-scoped Prisma helper

Create a helper function that automatically filters queries by tenant ID:

packages/database/src/tenant-scoped-client.ts
import { PrismaClient } from '@prisma/client';
import { getPrismaClient } from './client';

export function getTenantScopedClient(tenantId: string) {
    const prisma = getPrismaClient();
    
    // Return a proxy that automatically adds tenantId to queries
    return new Proxy(prisma, {
        get(target, prop) {
            const original = target[prop as keyof PrismaClient];
            
            if (typeof original === 'function' && prop !== '$connect' && prop !== '$disconnect') {
                return function (this: any, ...args: any[]) {
                    // For findMany, findFirst, etc., add tenantId filter
                    if (['findMany', 'findFirst', 'findUnique', 'count', 'aggregate'].includes(prop as string)) {
                        const [params] = args;
                        if (params?.where) {
                            params.where = {
                                ...params.where,
                                tenantId,
                            };
                        } else {
                            params.where = { tenantId };
                        }
                    }
                    
                    // For create, update, delete, ensure tenantId is set
                    if (['create', 'update', 'updateMany', 'delete', 'deleteMany'].includes(prop as string)) {
                        const [params] = args;
                        if (params?.data) {
                            params.data = {
                                ...params.data,
                                tenantId,
                            };
                        }
                    }
                    
                    return original.apply(this, args);
                };
            }
            
            return original;
        },
    });
}

// Alternative: Simpler approach with explicit tenant filtering
export function withTenant<T>(
    tenantId: string,
    query: (prisma: PrismaClient) => Promise<T>
): Promise<T> {
    const prisma = getPrismaClient();
    
    // Set tenant context on Prisma client
    // This requires custom Prisma extension or middleware
    return query(prisma);
}

Step 4: Use tenant context in server components

In your server components and API routes, extract the tenant ID and use it for all database queries:

apps/fastack-boilerplate/src/app/[locale]/projects/page.tsx
import { headers } from 'next/headers';
import { prisma } from '@/lib/db';
// Tenant context from headers or middleware (app-defined)
// const getTenantScopedClient = (tenantId: string) => prisma; // or app-specific scoping

export default async function ProjectsPage() {
    const headersList = await headers();
    const tenantId = headersList.get('x-tenant-id');
    
    if (!tenantId) {
        throw new Error('Tenant ID is required');
    }
    
    // Use app's Prisma client (tenant filtering in where clauses as needed)
    const db = prisma;
    const projects = await db.project.findMany({
        include: {
            owner: true,
        },
    });
    
    // Option 2: Explicitly filter by tenantId
    // const prisma = getPrismaClient();
    // const projects = await prisma.project.findMany({
    //     where: {
    //         tenantId,
    //     },
    //     include: {
    //         owner: true,
    //     },
    // });
    
    return (
        <div>
            <h1>Projects</h1>
            {projects.map((project) => (
                <div key={project.id}>
                    <h2>{project.name}</h2>
                    <p>{project.description}</p>
                </div>
            ))}
        </div>
    );
}

Security best practices

Security is critical in multi-tenant applications. Here are essential practices to prevent data leakage:

1. Always validate tenant access

Never trust client-provided tenant IDs. Always validate that the authenticated user belongs to the tenant they're requesting:

async function validateTenantAccess(userId: string, tenantId: string) {
    const user = await prisma.user.findUnique({
        where: { id: userId },
        select: { tenantId: true },
    });
    
    if (!user || user.tenantId !== tenantId) {
        throw new Error('Unauthorized: User does not belong to this tenant');
    }
    
    return true;
}

2. Use database row-level security (RLS)

For PostgreSQL, enable Row-Level Security policies to enforce tenant isolation at the database level:

-- Enable RLS on tables
ALTER TABLE "Project" ENABLE ROW LEVEL SECURITY;

-- Create policy that filters by tenant_id
CREATE POLICY tenant_isolation_policy ON "Project"
    USING (tenant_id = current_setting('app.current_tenant_id')::text);

-- Set tenant context before queries
SET app.current_tenant_id = 'tenant-123';
SELECT * FROM "Project"; -- Only returns projects for tenant-123

3. Implement tenant-aware middleware

Create middleware that automatically validates tenant access for all requests:

export async function withTenantAuth<T>(
    request: Request,
    handler: (tenantId: string, userId: string) => Promise<T>
) {
    const session = await getServerSession();
    if (!session?.user) {
        throw new Error('Unauthorized');
    }
    
    const tenantId = request.headers.get('x-tenant-id');
    if (!tenantId) {
        throw new Error('Tenant ID required');
    }
    
    // Validate user belongs to tenant
    await validateTenantAccess(session.user.id, tenantId);
    
    return handler(tenantId, session.user.id);
}

Performance optimization

Multi-tenant applications require careful performance optimization to handle scale:

1. Index tenant columns

Always index tenantId columns and composite indexes for common query patterns:

model Project {
  id        String   @id @default(cuid())
  tenantId  String
  ownerId   String
  status    String
  
  @@index([tenantId])
  @@index([tenantId, status]) // Composite index for filtered queries
  @@index([tenantId, ownerId]) // Composite index for user's projects
}

2. Implement connection pooling

Use Prisma connection pooling to efficiently handle multiple tenant queries:

// Use Prisma Data Proxy or PgBouncer for connection pooling
const prisma = new PrismaClient({
    datasources: {
        db: {
            url: process.env.DATABASE_URL, // Use pooled connection string
        },
    },
});

3. Cache tenant metadata

Cache frequently accessed tenant information to reduce database queries:

import { cache } from 'react';

export const getTenant = cache(async (tenantId: string) => {
    // React cache ensures this is only called once per request
    return await prisma.tenant.findUnique({
        where: { id: tenantId },
        select: {
            id: true,
            name: true,
            subdomain: true,
        },
    });
});

Subdomain-based tenant routing

For a better user experience, you can route tenants by subdomain (e.g., acme.yoursaas.com):

// middleware.ts
export function middleware(request: NextRequest) {
    const hostname = request.headers.get('host') || '';
    const parts = hostname.split('.');
    
    // Extract subdomain (first part before main domain)
    const subdomain = parts.length > 2 ? parts[0] : null;
    
    if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
        // Look up tenant by subdomain
        const tenant = await prisma.tenant.findUnique({
            where: { subdomain },
        });
        
        if (tenant) {
            const requestHeaders = new Headers(request.headers);
            requestHeaders.set('x-tenant-id', tenant.id);
            
            return NextResponse.next({
                request: {
                    headers: requestHeaders,
                },
            });
        }
    }
    
    // Default tenant or redirect to main app
    return NextResponse.redirect(new URL('/select-tenant', request.url));
}

Migration strategy

When adding multi-tenancy to an existing application, follow these steps:

  1. Create Tenant model: Add a Tenant table to your database
  2. Add tenantId to existing models: Migrate existing data to a default tenant
  3. Update queries: Add tenantId filters to all queries
  4. Add middleware: Implement tenant context extraction
  5. Test thoroughly: Verify data isolation with multiple tenants
// Migration: Add tenant support
// 1. Create default tenant
const defaultTenant = await prisma.tenant.create({
    data: {
        name: 'Default Tenant',
        subdomain: 'default',
    },
});

// 2. Add tenantId to existing users
await prisma.user.updateMany({
    data: {
        tenantId: defaultTenant.id,
    },
});

// 3. Add tenantId to other models
await prisma.$executeRaw`
    ALTER TABLE "Project" ADD COLUMN "tenantId" TEXT;
    UPDATE "Project" SET "tenantId" = $1;
    ALTER TABLE "Project" ALTER COLUMN "tenantId" SET NOT NULL;
`, defaultTenant.id);

Conclusion

Multi-tenant architecture is essential for building scalable SaaS applications. By implementing row-level security with tenant IDs, you can efficiently serve thousands of customers while maintaining data isolation and security. The key principles are:

  • Always filter queries by tenantId
  • Validate tenant access on every request
  • Use database indexes for performance
  • Implement proper connection pooling
  • Consider row-level security for additional protection

Start with the shared database approach and evolve to more complex patterns as your needs grow. With Next.js and Prisma, implementing multi-tenancy is straightforward and maintainable.

Ready to build your SaaS faster?

Get our scalable, production-ready boilerplate to save endless hours of development and setup

Multi-tenant SaaS Architecture: Building Scalable SaaS with Next.js - Fastack