Wednesday, March 5th 2025 ยท 3 min read

How to Implement Multi-Tenancy in Next.js: A Step-by-Step Guide

A practical guide to implementing multi-tenant architecture in Next.js. Learn database strategies, middleware patterns, and security considerations with working code examples.

Multi-tenancy is what transforms a single-user application into a scalable B2B SaaS. It allows multiple organizations (tenants) to use your application while keeping their data completely isolated.

In this step-by-step guide, we'll implement multi-tenancy in a Next.js application from scratch, covering database design, middleware, and security.

Understanding Multi-Tenancy Models

Before writing code, you need to choose a tenancy model:

1. Shared Database, Shared Schema

All tenants share tables with a tenant_id column:

-- Single users table for all tenants
CREATE TABLE users (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  email VARCHAR(255) NOT NULL,
  name VARCHAR(255)
);

-- Every query includes tenant filter
SELECT * FROM users WHERE tenant_id = 'abc-123';

Pros: Simple, cost-effective, easy migrations Cons: Query discipline required, potential for data leaks

2. Shared Database, Separate Schemas

Each tenant gets their own PostgreSQL schema:

-- Tenant-specific schema
CREATE SCHEMA tenant_abc123;
CREATE TABLE tenant_abc123.users (...);

-- Different tenant
CREATE SCHEMA tenant_xyz789;
CREATE TABLE tenant_xyz789.users (...);

Pros: Better isolation, same database instance Cons: Complex migrations, schema management overhead

3. Separate Databases

Each tenant gets a completely isolated database:

postgres://host/tenant_abc123
postgres://host/tenant_xyz789

Pros: Maximum isolation, independent scaling Cons: Expensive, complex deployment

For most SaaS applications, shared database with shared schema provides the best balance of simplicity, cost, and security. That's what we'll implement.

Step 1: Database Schema Design

Let's design a multi-tenant schema using Prisma:

// prisma/schema.prisma

model Organization {
  id        String   @id @default(cuid())
  name      String
  slug      String   @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  memberships Membership[]
  invitations Invitation[]
  projects    Project[]

  @@map("organizations")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  image     String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  memberships Membership[]
  invitations Invitation[] @relation("InvitedBy")

  @@map("users")
}

model Membership {
  id             String   @id @default(cuid())
  role           Role     @default(MEMBER)
  createdAt      DateTime @default(now())

  // Relations
  user           User         @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId         String
  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  organizationId String

  @@unique([userId, organizationId])
  @@map("memberships")
}

model Project {
  id             String   @id @default(cuid())
  name           String
  description    String?
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  // Tenant isolation
  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  organizationId String

  @@map("projects")
}

enum Role {
  OWNER
  ADMIN
  MEMBER
}

Key design decisions:

  • Users exist independently of organizations (can belong to multiple)
  • Membership is the join table with role information
  • All tenant resources have an organizationId foreign key

Step 2: Organization Context Provider

Create a React context to track the current organization:

// contexts/organization-context.tsx
'use client';

import { createContext, useContext, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';

type Organization = {
  id: string;
  name: string;
  slug: string;
  role: 'OWNER' | 'ADMIN' | 'MEMBER';
};

type OrganizationContextType = {
  organization: Organization | null;
  setOrganization: (org: Organization | null) => void;
  isLoading: boolean;
};

const OrganizationContext = createContext<OrganizationContextType | undefined>(
  undefined
);

export function OrganizationProvider({
  children,
  initialOrganization
}: {
  children: React.ReactNode;
  initialOrganization: Organization | null;
}) {
  const [organization, setOrganization] = useState<Organization | null>(
    initialOrganization
  );
  const [isLoading, setIsLoading] = useState(false);

  return (
    <OrganizationContext.Provider
      value={{ organization, setOrganization, isLoading }}
    >
      {children}
    </OrganizationContext.Provider>
  );
}

export function useOrganization() {
  const context = useContext(OrganizationContext);
  if (context === undefined) {
    throw new Error('useOrganization must be used within OrganizationProvider');
  }
  return context;
}

Step 3: Tenant-Aware Data Access Layer

Create a data access layer that enforces tenant isolation:

// lib/dal/projects.ts
import { getCurrentOrganization } from '@/lib/auth';
import { db } from '@/lib/db';

export async function getProjects() {
  const org = await getCurrentOrganization();

  if (!org) {
    throw new Error('No organization selected');
  }

  return db.project.findMany({
    where: {
      organizationId: org.id // Always filter by tenant
    },
    orderBy: {
      createdAt: 'desc'
    }
  });
}

export async function getProject(id: string) {
  const org = await getCurrentOrganization();

  if (!org) {
    throw new Error('No organization selected');
  }

  const project = await db.project.findFirst({
    where: {
      id,
      organizationId: org.id // Prevent accessing other tenants' data
    }
  });

  if (!project) {
    throw new Error('Project not found');
  }

  return project;
}

export async function createProject(data: {
  name: string;
  description?: string;
}) {
  const org = await getCurrentOrganization();

  if (!org) {
    throw new Error('No organization selected');
  }

  return db.project.create({
    data: {
      ...data,
      organizationId: org.id // Always set tenant
    }
  });
}

export async function deleteProject(id: string) {
  const org = await getCurrentOrganization();

  if (!org) {
    throw new Error('No organization selected');
  }

  // Verify ownership before deletion
  const project = await db.project.findFirst({
    where: {
      id,
      organizationId: org.id
    }
  });

  if (!project) {
    throw new Error('Project not found');
  }

  return db.project.delete({
    where: { id }
  });
}

Step 4: Middleware for Tenant Resolution

Create middleware to resolve the tenant from the URL or session:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Skip middleware for public routes
  if (
    pathname.startsWith('/api/auth') ||
    pathname.startsWith('/_next') ||
    pathname === '/login' ||
    pathname === '/signup'
  ) {
    return NextResponse.next();
  }

  // Check authentication
  const token = await getToken({ req: request });

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // Check if accessing organization routes
  if (pathname.startsWith('/dashboard/')) {
    const orgSlug = pathname.split('/')[2];

    if (orgSlug) {
      // Verify user has access to this organization
      // In production, you'd cache this check
      const hasAccess = await verifyOrganizationAccess(
        token.sub as string,
        orgSlug
      );

      if (!hasAccess) {
        return NextResponse.redirect(new URL('/dashboard', request.url));
      }

      // Add organization to headers for server components
      const response = NextResponse.next();
      response.headers.set('x-organization-slug', orgSlug);
      return response;
    }
  }

  return NextResponse.next();
}

async function verifyOrganizationAccess(
  userId: string,
  orgSlug: string
): Promise<boolean> {
  // Implement your verification logic
  // This should be cached/optimized in production
  return true;
}

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

Step 5: Organization Switching UI

Build a component for switching between organizations:

// components/organization-switcher.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useOrganization } from '@/contexts/organization-context';
import { Check, ChevronsUpDown, PlusCircle } from 'lucide-react';

import { Button } from '@/components/ui/button';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator
} from '@/components/ui/command';
import {
  Popover,
  PopoverContent,
  PopoverTrigger
} from '@/components/ui/popover';

type Organization = {
  id: string;
  name: string;
  slug: string;
  role: string;
};

export function OrganizationSwitcher({
  organizations
}: {
  organizations: Organization[];
}) {
  const [open, setOpen] = useState(false);
  const router = useRouter();
  const { organization, setOrganization } = useOrganization();

  const handleSelect = (org: Organization) => {
    setOrganization(org);
    setOpen(false);
    router.push(`/dashboard/${org.slug}`);
  };

  return (
    <Popover
      open={open}
      onOpenChange={setOpen}
    >
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          role="combobox"
          aria-expanded={open}
          className="w-[200px] justify-between"
        >
          {organization?.name ?? 'Select organization...'}
          <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-[200px] p-0">
        <Command>
          <CommandInput placeholder="Search organization..." />
          <CommandList>
            <CommandEmpty>No organization found.</CommandEmpty>
            <CommandGroup heading="Organizations">
              {organizations.map((org) => (
                <CommandItem
                  key={org.id}
                  onSelect={() => handleSelect(org)}
                >
                  <Check
                    className={`mr-2 h-4 w-4 ${
                      organization?.id === org.id ? 'opacity-100' : 'opacity-0'
                    }`}
                  />
                  {org.name}
                </CommandItem>
              ))}
            </CommandGroup>
            <CommandSeparator />
            <CommandGroup>
              <CommandItem
                onSelect={() => {
                  setOpen(false);
                  router.push('/dashboard/new');
                }}
              >
                <PlusCircle className="mr-2 h-4 w-4" />
                Create Organization
              </CommandItem>
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

Step 6: Server Actions with Tenant Isolation

Create secure server actions that enforce tenant boundaries:

// actions/projects.ts
'use server';

import { revalidatePath } from 'next/cache';
import { z } from 'zod';

import { getCurrentOrganization, getCurrentUser } from '@/lib/auth';
import { db } from '@/lib/db';

const createProjectSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().max(500).optional()
});

export async function createProjectAction(formData: FormData) {
  const user = await getCurrentUser();
  const org = await getCurrentOrganization();

  if (!user || !org) {
    return { error: 'Unauthorized' };
  }

  // Check user has permission to create projects
  if (org.role === 'MEMBER') {
    return { error: 'Insufficient permissions' };
  }

  const validated = createProjectSchema.safeParse({
    name: formData.get('name'),
    description: formData.get('description')
  });

  if (!validated.success) {
    return { error: 'Invalid input' };
  }

  try {
    const project = await db.project.create({
      data: {
        name: validated.data.name,
        description: validated.data.description,
        organizationId: org.id // Always set from session, never from client
      }
    });

    revalidatePath(`/dashboard/${org.slug}/projects`);
    return { success: true, project };
  } catch (error) {
    return { error: 'Failed to create project' };
  }
}

export async function deleteProjectAction(projectId: string) {
  const user = await getCurrentUser();
  const org = await getCurrentOrganization();

  if (!user || !org) {
    return { error: 'Unauthorized' };
  }

  // Verify the project belongs to current organization
  const project = await db.project.findFirst({
    where: {
      id: projectId,
      organizationId: org.id // Critical: prevents cross-tenant access
    }
  });

  if (!project) {
    return { error: 'Project not found' };
  }

  // Check permissions
  if (org.role !== 'OWNER' && org.role !== 'ADMIN') {
    return { error: 'Insufficient permissions' };
  }

  await db.project.delete({
    where: { id: projectId }
  });

  revalidatePath(`/dashboard/${org.slug}/projects`);
  return { success: true };
}

Step 7: Role-Based Access Control

Implement RBAC for fine-grained permissions:

// lib/permissions.ts
type Permission =
  | 'project:create'
  | 'project:read'
  | 'project:update'
  | 'project:delete'
  | 'member:invite'
  | 'member:remove'
  | 'billing:manage'
  | 'organization:settings';

const rolePermissions: Record<string, Permission[]> = {
  OWNER: [
    'project:create',
    'project:read',
    'project:update',
    'project:delete',
    'member:invite',
    'member:remove',
    'billing:manage',
    'organization:settings'
  ],
  ADMIN: [
    'project:create',
    'project:read',
    'project:update',
    'project:delete',
    'member:invite',
    'member:remove'
  ],
  MEMBER: ['project:read', 'project:update']
};

export function hasPermission(role: string, permission: Permission): boolean {
  return rolePermissions[role]?.includes(permission) ?? false;
}

export function requirePermission(role: string, permission: Permission) {
  if (!hasPermission(role, permission)) {
    throw new Error(`Missing permission: ${permission}`);
  }
}

Security Considerations

1. Never Trust Client Input

// BAD: organizationId from client
await db.project.create({
  data: {
    name: data.name,
    organizationId: data.organizationId // Attacker can set any ID!
  }
});

// GOOD: organizationId from server session
const org = await getCurrentOrganization();
await db.project.create({
  data: {
    name: data.name,
    organizationId: org.id // Always from authenticated session
  }
});

2. Always Filter Queries

// BAD: No tenant filter
const project = await db.project.findUnique({
  where: { id: projectId }
});

// GOOD: Always include tenant filter
const project = await db.project.findFirst({
  where: {
    id: projectId,
    organizationId: org.id
  }
});

3. Use Database-Level Policies

For PostgreSQL, add row-level security as a safety net:

-- Enable RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Policy: Users can only see their organization's projects
CREATE POLICY tenant_isolation ON projects
  FOR ALL
  USING (organization_id = current_setting('app.current_organization_id')::uuid);

Testing Multi-Tenancy

Write tests that verify tenant isolation:

// tests/multi-tenancy.test.ts
import { describe, expect, it } from 'vitest';

describe('Multi-tenancy isolation', () => {
  it('should not allow cross-tenant project access', async () => {
    // Create two organizations
    const org1 = await createOrganization('Org 1');
    const org2 = await createOrganization('Org 2');

    // Create project in org1
    const project = await createProject(org1.id, { name: 'Secret Project' });

    // Try to access from org2 context
    const result = await getProject(org2.id, project.id);

    expect(result).toBeNull();
  });

  it('should prevent tenant ID manipulation', async () => {
    const org1 = await createOrganization('Org 1');
    const org2 = await createOrganization('Org 2');

    // Try to create project in org2 while authenticated as org1
    const result = await createProjectAction({
      name: 'Malicious Project',
      organizationId: org2.id // Should be ignored
    });

    // Project should be created in org1, not org2
    const project = await db.project.findFirst({
      where: { name: 'Malicious Project' }
    });

    expect(project?.organizationId).toBe(org1.id);
  });
});

Conclusion

Implementing multi-tenancy requires careful attention to data isolation at every layer:

  1. Database design with proper foreign keys and constraints
  2. Server-side context to track the current tenant
  3. Data access layer that enforces tenant filters
  4. Middleware to verify tenant access
  5. Server actions that never trust client input
  6. RBAC for fine-grained permissions
  7. Testing to verify isolation

The patterns in this guide scale from startups to enterpriseโ€”the key is building tenant isolation into your architecture from the start.


Want multi-tenancy without building it yourself? Achromatic includes production-ready organization management, member invites, role-based permissions, and tenant isolation out of the box.