Skip to main content
Back to Blog
Tutorials
3 min read
November 25, 2024

How to Build a Multi-Tenant SaaS App Structure in Next.js

Structure a multi-tenant SaaS application in Next.js with subdomain routing, tenant isolation, and shared infrastructure.

Ryel Banfield

Founder & Lead Developer

Multi-tenancy lets you serve multiple customers from a single codebase. Here is how to structure it in Next.js.

Multi-Tenancy Approaches

ApproachProsCons
Subdomain per tenantClean URLs, easy routingDNS setup needed
Path-based (/org/acme)Simpler setupMessier URLs
Database-level isolationStrongest isolationMore complex

Step 1: Middleware for Subdomain Routing

// middleware.ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(req: NextRequest) {
  const hostname = req.headers.get("host") || "";
  const currentHost = hostname.replace(`.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`, "");

  // Skip for root domain and special subdomains
  if (
    currentHost === process.env.NEXT_PUBLIC_ROOT_DOMAIN ||
    currentHost === "www" ||
    currentHost === "app"
  ) {
    return NextResponse.next();
  }

  // Rewrite to tenant-specific routes
  const url = req.nextUrl;
  url.pathname = `/tenant/${currentHost}${url.pathname}`;
  return NextResponse.rewrite(url);
}

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

Step 2: Tenant Resolution

// lib/tenant.ts
import { db } from "@/db";
import { tenants } from "@/db/schema";
import { eq } from "drizzle-orm";
import { cache } from "react";

export interface Tenant {
  id: string;
  slug: string;
  name: string;
  domain?: string;
  plan: "free" | "pro" | "enterprise";
  settings: Record<string, unknown>;
}

export const getTenant = cache(async (slug: string): Promise<Tenant | null> => {
  const [tenant] = await db
    .select()
    .from(tenants)
    .where(eq(tenants.slug, slug))
    .limit(1);

  return tenant || null;
});

export const getTenantByDomain = cache(async (domain: string): Promise<Tenant | null> => {
  const [tenant] = await db
    .select()
    .from(tenants)
    .where(eq(tenants.domain, domain))
    .limit(1);

  return tenant || null;
});

Step 3: Tenant-Scoped Layout

// app/tenant/[slug]/layout.tsx
import { notFound } from "next/navigation";
import { getTenant } from "@/lib/tenant";
import { TenantProvider } from "@/components/TenantProvider";

export default async function TenantLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const tenant = await getTenant(slug);

  if (!tenant) notFound();

  return (
    <TenantProvider tenant={tenant}>
      <div
        style={{
          "--brand-color": tenant.settings.brandColor as string || "#3b82f6",
        } as React.CSSProperties}
      >
        {children}
      </div>
    </TenantProvider>
  );
}

Step 4: Tenant Context Provider

"use client";

import { createContext, useContext } from "react";
import type { Tenant } from "@/lib/tenant";

const TenantContext = createContext<Tenant | null>(null);

export function TenantProvider({
  tenant,
  children,
}: {
  tenant: Tenant;
  children: React.ReactNode;
}) {
  return (
    <TenantContext.Provider value={tenant}>{children}</TenantContext.Provider>
  );
}

export function useTenant() {
  const tenant = useContext(TenantContext);
  if (!tenant) throw new Error("useTenant must be used within TenantProvider");
  return tenant;
}

Step 5: Database Schema with Tenant Isolation

// db/schema.ts
import { pgTable, text, timestamp, uuid, index } from "drizzle-orm/pg-core";

export const tenants = pgTable("tenants", {
  id: uuid("id").defaultRandom().primaryKey(),
  slug: text("slug").notNull().unique(),
  name: text("name").notNull(),
  domain: text("domain").unique(),
  plan: text("plan").$type<"free" | "pro" | "enterprise">().default("free"),
  settings: text("settings").$type<string>().default("{}"),
  createdAt: timestamp("created_at").defaultNow(),
});

// All data tables include tenantId
export const projects = pgTable(
  "projects",
  {
    id: uuid("id").defaultRandom().primaryKey(),
    tenantId: uuid("tenant_id")
      .notNull()
      .references(() => tenants.id, { onDelete: "cascade" }),
    name: text("name").notNull(),
    createdAt: timestamp("created_at").defaultNow(),
  },
  (table) => ({
    tenantIdx: index("projects_tenant_idx").on(table.tenantId),
  })
);

export const members = pgTable("members", {
  id: uuid("id").defaultRandom().primaryKey(),
  tenantId: uuid("tenant_id")
    .notNull()
    .references(() => tenants.id, { onDelete: "cascade" }),
  userId: text("user_id").notNull(),
  role: text("role").$type<"owner" | "admin" | "member">().default("member"),
  createdAt: timestamp("created_at").defaultNow(),
});

Step 6: Tenant-Scoped Queries

// lib/queries.ts
import { db } from "@/db";
import { projects, members } from "@/db/schema";
import { eq, and } from "drizzle-orm";

// Always filter by tenantId
export async function getProjects(tenantId: string) {
  return db
    .select()
    .from(projects)
    .where(eq(projects.tenantId, tenantId));
}

export async function getProject(tenantId: string, projectId: string) {
  const [project] = await db
    .select()
    .from(projects)
    .where(
      and(
        eq(projects.tenantId, tenantId),
        eq(projects.id, projectId)
      )
    );
  return project || null;
}

// Verify membership before any operation
export async function verifyMembership(tenantId: string, userId: string) {
  const [member] = await db
    .select()
    .from(members)
    .where(
      and(
        eq(members.tenantId, tenantId),
        eq(members.userId, userId)
      )
    );
  return member || null;
}

Step 7: Tenant-Aware API Routes

// app/api/tenant/[slug]/projects/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getTenant } from "@/lib/tenant";
import { verifyMembership, getProjects } from "@/lib/queries";

export async function GET(
  req: Request,
  { params }: { params: Promise<{ slug: string }> }
) {
  const session = await auth();
  if (!session?.user?.id) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { slug } = await params;
  const tenant = await getTenant(slug);
  if (!tenant) {
    return NextResponse.json({ error: "Tenant not found" }, { status: 404 });
  }

  const member = await verifyMembership(tenant.id, session.user.id);
  if (!member) {
    return NextResponse.json({ error: "Not a member" }, { status: 403 });
  }

  const projects = await getProjects(tenant.id);
  return NextResponse.json(projects);
}

Need a Multi-Tenant SaaS Platform?

We build scalable SaaS applications with multi-tenancy, tenant isolation, and enterprise features. Contact us to discuss your project.

multi-tenantSaaSsubdomainsNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles