Blog/Tutorials & Step-by-Step/Build a Multi-Tenant SaaS App with Next.js
POST
August 12, 2025
LAST UPDATEDAugust 12, 2025

Build a Multi-Tenant SaaS App with Next.js

Learn how to build a multi-tenant SaaS application with Next.js using subdomain routing, tenant-aware middleware, and data isolation strategies.

Tags

Multi-TenantSaaSNext.jsArchitecture
Build a Multi-Tenant SaaS App with Next.js
6 min read

Build a Multi-Tenant SaaS App with Next.js

In this tutorial, you will build the foundational architecture for a multi-tenant SaaS application using Next.js. You will implement tenant resolution from subdomains, create middleware that injects tenant context into every request, design a database schema with proper tenant isolation, and build a data access layer that prevents cross-tenant data leaks.

Multi-tenancy is the architecture pattern that allows a single application instance to serve multiple customers (tenants), each with their own isolated data, configuration, and often their own subdomain. It is the backbone of every SaaS product from Slack to Notion to Linear.

TL;DR

Resolve the tenant from the subdomain in Next.js middleware, store the tenant ID in a request header, scope all database queries with a tenant_id foreign key using a repository pattern, and use Prisma middleware to enforce tenant isolation automatically. This shared-database approach scales well for most SaaS applications.

Prerequisites

  • Next.js 14+ with the App Router
  • Prisma (or another TypeScript ORM)
  • Basic understanding of DNS and subdomains
  • A PostgreSQL database

Install the dependencies:

bash
npm install prisma @prisma/client
npx prisma init

Step 1: Choose Your Tenant Isolation Strategy

There are three main approaches to multi-tenancy, each with different trade-offs.

Shared Database with tenant_id (recommended for most SaaS):

  • All tenants share the same database and tables
  • Every table has a tenant_id column
  • Simplest to manage, migrate, and scale
  • Requires careful query scoping to prevent data leaks

Separate Schema per Tenant:

  • Each tenant gets their own database schema within one database
  • Better isolation than shared tables
  • Schema migrations must be applied to all schemas
  • Moderate complexity

Separate Database per Tenant:

  • Each tenant gets a completely separate database
  • Maximum isolation and independent scaling
  • Highest operational complexity
  • Reserved for regulated industries or extreme scale requirements

For this tutorial, we will use the shared database approach with tenant_id because it provides the best balance of simplicity and scalability.

Step 2: Design the Database Schema

Define your Prisma schema with tenant isolation built in from the start.

prisma
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model Tenant {
  id          String   @id @default(cuid())
  name        String
  slug        String   @unique // Used as subdomain
  customDomain String? @unique
  plan        String   @default("free")
  settings    Json     @default("{}")
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  users    User[]
  projects Project[]
 
  @@index([slug])
  @@index([customDomain])
}
 
model User {
  id        String   @id @default(cuid())
  email     String
  name      String
  role      String   @default("member") // "owner", "admin", "member"
  tenantId  String
  tenant    Tenant   @relation(fields: [tenantId], references: [id])
  createdAt DateTime @default(now())
 
  projects  Project[]
 
  @@unique([email, tenantId]) // Same email can exist in different tenants
  @@index([tenantId])
}
 
model Project {
  id          String   @id @default(cuid())
  name        String
  description String?
  status      String   @default("active")
  tenantId    String
  tenant      Tenant   @relation(fields: [tenantId], references: [id])
  createdBy   String
  creator     User     @relation(fields: [createdBy], references: [id])
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
 
  @@index([tenantId])
  @@index([tenantId, status])
}

Every table that contains tenant-specific data includes a tenantId column with an index. The @@unique([email, tenantId]) constraint on the User model allows the same email address to exist in different tenants, which is important because users may belong to multiple organizations.

Run the migration:

bash
npx prisma migrate dev --name init-multi-tenant

Step 3: Build Tenant Resolution Middleware

Next.js middleware runs on every request before it reaches your pages or API routes. Use it to extract the tenant from the subdomain.

typescript
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
 
// Subdomains that are not tenant slugs
const RESERVED_SUBDOMAINS = new Set(["www", "app", "api", "admin"]);
 
export function middleware(request: NextRequest) {
  const hostname = request.headers.get("host") || "";
  const url = request.nextUrl.clone();
 
  // Extract subdomain
  // In production: tenant.yoursaas.com
  // In development: tenant.localhost:3000
  const baseDomain = process.env.NEXT_PUBLIC_BASE_DOMAIN || "localhost:3000";
  const subdomain = hostname.replace(`.${baseDomain}`, "").split(".")[0];
 
  // Check if this is a tenant subdomain
  const isTenantSubdomain =
    subdomain &&
    subdomain !== baseDomain.split(":")[0] &&
    !RESERVED_SUBDOMAINS.has(subdomain);
 
  if (isTenantSubdomain) {
    // Pass tenant slug to the application via headers
    const response = NextResponse.next();
    response.headers.set("x-tenant-slug", subdomain);
    return response;
  }
 
  // If no subdomain, serve the marketing site or login page
  return NextResponse.next();
}
 
export const config = {
  matcher: [
    // Match all paths except static files and Next.js internals
    "/((?!_next/static|_next/image|favicon.ico).*)",
  ],
};

The middleware extracts the subdomain from the hostname, checks that it is not a reserved subdomain like www or api, and forwards the tenant slug as a custom header. This approach is stateless and does not require a database lookup in middleware, keeping latency minimal.

Step 4: Create the Tenant Context

Build a server-side utility that resolves the full tenant from the slug and makes it available throughout your request lifecycle.

typescript
// lib/tenant.ts
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { cache } from "react";
import { notFound } from "next/navigation";
 
export interface TenantContext {
  id: string;
  name: string;
  slug: string;
  plan: string;
  settings: Record<string, unknown>;
}
 
// cache() ensures this only runs once per request
export const getCurrentTenant = cache(async (): Promise<TenantContext> => {
  const headersList = await headers();
  const slug = headersList.get("x-tenant-slug");
 
  if (!slug) {
    notFound();
  }
 
  const tenant = await prisma.tenant.findUnique({
    where: { slug },
    select: {
      id: true,
      name: true,
      slug: true,
      plan: true,
      settings: true,
    },
  });
 
  if (!tenant) {
    notFound();
  }
 
  return {
    ...tenant,
    settings: tenant.settings as Record<string, unknown>,
  };
});

The cache() wrapper from React ensures that even if multiple components call getCurrentTenant() during the same request, the database query only executes once. This is important because Server Components in the App Router can independently call this function without worrying about duplicate queries.

Step 5: Build the Tenant-Scoped Data Access Layer

This is the most critical piece. Create a repository pattern that automatically scopes every query to the current tenant.

typescript
// lib/repositories/project-repository.ts
import { prisma } from "@/lib/prisma";
import { getCurrentTenant } from "@/lib/tenant";
 
export async function getProjects() {
  const tenant = await getCurrentTenant();
 
  return prisma.project.findMany({
    where: { tenantId: tenant.id },
    orderBy: { createdAt: "desc" },
  });
}
 
export async function getProjectById(projectId: string) {
  const tenant = await getCurrentTenant();
 
  const project = await prisma.project.findFirst({
    where: {
      id: projectId,
      tenantId: tenant.id, // Always scope by tenant
    },
  });
 
  return project;
}
 
export async function createProject(data: {
  name: string;
  description?: string;
  createdBy: string;
}) {
  const tenant = await getCurrentTenant();
 
  return prisma.project.create({
    data: {
      ...data,
      tenantId: tenant.id,
    },
  });
}
 
export async function updateProject(
  projectId: string,
  data: { name?: string; description?: string; status?: string }
) {
  const tenant = await getCurrentTenant();
 
  // First verify the project belongs to this tenant
  const existing = await prisma.project.findFirst({
    where: { id: projectId, tenantId: tenant.id },
  });
 
  if (!existing) {
    throw new Error("Project not found");
  }
 
  return prisma.project.update({
    where: { id: projectId },
    data,
  });
}
 
export async function deleteProject(projectId: string) {
  const tenant = await getCurrentTenant();
 
  // Verify ownership before deletion
  const existing = await prisma.project.findFirst({
    where: { id: projectId, tenantId: tenant.id },
  });
 
  if (!existing) {
    throw new Error("Project not found");
  }
 
  return prisma.project.delete({
    where: { id: projectId },
  });
}

Notice that every function includes tenantId: tenant.id in the where clause. This prevents a user from accessing another tenant's data by guessing project IDs. The findFirst pattern with both id and tenantId is safer than findUnique with just id because it ensures tenant scoping even if someone tries to pass a valid project ID from another tenant.

Step 6: Add Prisma Middleware for Automatic Scoping

For an additional safety layer, add Prisma middleware that automatically injects tenant scoping. This catches any query that accidentally omits the tenant filter.

typescript
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
 
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};
 
export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
  });
 
if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}
 
// Soft-delete middleware or query logging can be added here
prisma.$use(async (params, next) => {
  const tenantScopedModels = ["Project", "User"];
 
  if (
    params.model &&
    tenantScopedModels.includes(params.model) &&
    ["findMany", "findFirst", "count", "aggregate"].includes(params.action)
  ) {
    // Log a warning if tenantId is missing from the query
    const where = params.args?.where;
    if (where && !where.tenantId) {
      console.warn(
        `WARNING: Query on ${params.model}.${params.action} missing tenantId filter`
      );
    }
  }
 
  return next(params);
});

This middleware logs a warning whenever a query on a tenant-scoped model does not include a tenantId filter. In production, you could escalate this to throw an error to prevent any unscoped query from executing.

Step 7: Build Tenant-Aware Pages

Use the tenant context in your Server Components to display tenant-specific data.

typescript
// app/dashboard/page.tsx
import { getCurrentTenant } from "@/lib/tenant";
import { getProjects } from "@/lib/repositories/project-repository";
 
export default async function DashboardPage() {
  const tenant = await getCurrentTenant();
  const projects = await getProjects();
 
  return (
    <div className="min-h-screen bg-black p-8">
      <div className="mx-auto max-w-6xl">
        <div className="mb-8">
          <h1 className="text-2xl font-bold text-white">{tenant.name}</h1>
          <p className="text-gray-400">
            Plan: {tenant.plan} | {projects.length} projects
          </p>
        </div>
 
        <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
          {projects.map((project) => (
            <div
              key={project.id}
              className="rounded-xl border border-gray-800 bg-gray-900 p-6"
            >
              <h3 className="font-semibold text-white">{project.name}</h3>
              <p className="mt-2 text-sm text-gray-400">
                {project.description || "No description"}
              </p>
              <span className="mt-3 inline-block rounded-full bg-gray-800 px-3 py-1 text-xs text-gray-300">
                {project.status}
              </span>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

Because getCurrentTenant() is cached per request and getProjects() always scopes by tenant, this component is guaranteed to only show data belonging to the current tenant. No additional filtering is needed at the component level.

Step 8: Handle Tenant Onboarding

Create an API route for registering new tenants and provisioning their workspace.

typescript
// app/api/tenants/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
 
const createTenantSchema = z.object({
  name: z.string().min(2).max(50),
  slug: z
    .string()
    .min(3)
    .max(30)
    .regex(/^[a-z0-9-]+$/, "Slug must contain only lowercase letters, numbers, and hyphens"),
  ownerEmail: z.string().email(),
  ownerName: z.string().min(2),
});
 
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const data = createTenantSchema.parse(body);
 
    // Check if slug is already taken
    const existing = await prisma.tenant.findUnique({
      where: { slug: data.slug },
    });
 
    if (existing) {
      return NextResponse.json(
        { error: "This workspace URL is already taken" },
        { status: 409 }
      );
    }
 
    // Create tenant and owner in a transaction
    const result = await prisma.$transaction(async (tx) => {
      const tenant = await tx.tenant.create({
        data: {
          name: data.name,
          slug: data.slug,
          plan: "free",
          settings: {
            features: { analytics: false, api: false, customBranding: false },
            limits: { maxUsers: 5, maxProjects: 10 },
          },
        },
      });
 
      const owner = await tx.user.create({
        data: {
          email: data.ownerEmail,
          name: data.ownerName,
          role: "owner",
          tenantId: tenant.id,
        },
      });
 
      return { tenant, owner };
    });
 
    return NextResponse.json(
      {
        tenant: { id: result.tenant.id, slug: result.tenant.slug },
        redirectUrl: `https://${result.tenant.slug}.${process.env.NEXT_PUBLIC_BASE_DOMAIN}`,
      },
      { status: 201 }
    );
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: "Validation failed", details: error.errors },
        { status: 400 }
      );
    }
    console.error("Tenant creation failed:", error);
    return NextResponse.json(
      { error: "Failed to create workspace" },
      { status: 500 }
    );
  }
}

The transaction ensures that either both the tenant and the owner user are created, or neither is. The settings JSON field stores plan-specific feature flags and limits that control what each tenant can access.

Step 9: Configure Local Development

To test subdomains locally, update your system's hosts file and Next.js configuration.

bash
# Add to /etc/hosts (Mac/Linux) or C:\Windows\System32\drivers\etc\hosts (Windows)
127.0.0.1 acme.localhost
127.0.0.1 globex.localhost
typescript
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  // Allow subdomain access in development
  async rewrites() {
    return {
      beforeFiles: [],
      afterFiles: [],
      fallback: [],
    };
  },
};
 
export default nextConfig;

Add the base domain to your environment:

bash
# .env.local
NEXT_PUBLIC_BASE_DOMAIN=localhost:3000
DATABASE_URL=postgresql://user:password@localhost:5432/saas_db

Now you can access http://acme.localhost:3000 and http://globex.localhost:3000 as separate tenants during development.

The Complete Architecture

The multi-tenant architecture works in these layers:

  1. DNS Layer: Wildcard DNS record (*.yoursaas.com) points all subdomains to your application
  2. Middleware Layer: Extracts the tenant slug from the subdomain and passes it via headers
  3. Tenant Resolution Layer: Looks up the tenant from the database, cached per request
  4. Data Access Layer: Every query is scoped by tenantId, enforced by the repository pattern
  5. Safety Layer: Prisma middleware warns about unscoped queries as a last line of defense

Next Steps

  • Add authentication with a library like Better Auth that supports organization-based multi-tenancy
  • Implement role-based access control (RBAC) with tenant-specific roles and permissions
  • Add custom domain support using the customDomain field with SSL certificate provisioning
  • Build a tenant admin panel for managing users, billing, and settings
  • Implement rate limiting per tenant to prevent abuse
  • Add tenant-specific theming using the settings JSON field to store brand colors and logos

FAQ

Should I use a separate database per tenant or a shared database?

For most SaaS applications, a shared database with a tenant_id column on every table is the right choice. It is simpler to manage migrations, perform backups, and scale horizontally. Use separate databases only when regulatory requirements demand physical data isolation, or when individual tenants have extremely large datasets that need independent scaling.

How do I handle custom domains for tenants?

Use Next.js middleware to check the request hostname against a database table of custom domain mappings. When a custom domain is detected, resolve it to the corresponding tenant. For SSL, use a wildcard certificate for your primary domain and an automated certificate provisioning service like Let's Encrypt with DNS-01 challenges for custom domains.

How do I prevent data leaks between tenants?

Enforce tenant scoping at the data access layer using a repository pattern, not at the API route level. Every database query must include a tenantId filter. Add Prisma middleware that logs or blocks unscoped queries as a safety net. For maximum security, enable PostgreSQL Row Level Security (RLS) policies that enforce tenant isolation at the database engine level.

How do I handle tenant-specific feature flags?

Store feature flags in a settings JSON column on the Tenant model. Load the tenant settings during resolution in middleware and make them available throughout your application. This approach lets each tenant have different features enabled based on their subscription plan without deploying separate code for each tenant.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Add Observability to a Node.js App with OpenTelemetry
Mar 21, 20265 min read
Node.js
OpenTelemetry
Observability

How to Add Observability to a Node.js App with OpenTelemetry

Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
Mar 21, 20266 min read
Next.js
Node.js
BFF

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js

A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.

How I Structure CI/CD for Next.js, Docker, and GitHub Actions
Mar 21, 20265 min read
CI/CD
Next.js
Docker

How I Structure CI/CD for Next.js, Docker, and GitHub Actions

A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.