Web Engineering ·

Building Multi-Tenant SaaS with Next.js: Tenant Routing, Theming, and Data Isolation in the App Router

A practical guide to multi-tenant SaaS architecture in Next.js App Router. Covers tenant identification (subdomain, path prefix, custom domain), middleware routing, per-tenant theming with CSS variables and server components, database isolation patterns with RLS, caching with tenant context, and authentication flows. TypeScript examples throughout.

Building Multi-Tenant SaaS with Next.js: Tenant Routing, Theming, and Data Isolation in the App Router

Multi-tenancy is the architectural pattern behind most B2B SaaS products. One codebase, one deployment, many customers, each seeing their own data, their own branding, and (ideally) none of each other’s data. The concept is straightforward. The implementation in a framework like Next.js, especially with the App Router, has enough sharp edges that it deserves a thorough walkthrough.

This article covers the full stack: how tenants get identified from the incoming request, how routing works in middleware, how theming adapts per tenant using server components and CSS variables, which database isolation patterns work and when, and how caching and authentication fit into the picture. All examples are in TypeScript.

Tenant Identification Strategies

Before you can route, theme, or isolate anything, you need to answer: how does the system know which tenant this request belongs to? There are three common approaches, each with different tradeoffs.

Subdomain-based (acme.yourapp.com). This is the most common pattern for B2B SaaS. The tenant slug lives in the hostname, which keeps URL paths clean. The downside is DNS and SSL configuration. You need a wildcard DNS record (*.yourapp.com) and a wildcard TLS certificate, which most providers handle well but which complicates local development. For local dev, tools like nip.io or editing /etc/hosts work but add friction.

Path prefix (yourapp.com/acme/dashboard). Simpler to set up, no DNS changes required. The tenant slug becomes the first path segment. The tradeoff is that every route now needs to account for the prefix, and it can create confusion with marketing pages that live at the root. This works well for internal tools or admin panels where the URL structure is less visible to end users.

Custom domain (app.acme.com). Enterprise customers often want their own domain. This requires a domain lookup table and dynamic TLS provisioning (Cloudflare for SaaS, Vercel’s custom domain API, or Let’s Encrypt with a challenge). It is the most complex option but often necessary at the enterprise tier.

In practice, many SaaS products start with subdomain routing and add custom domain support later.

Middleware-Based Tenant Routing

Next.js middleware runs at the edge before any page or API route. This is the right place to resolve the tenant.

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

export async function middleware(request: NextRequest) {
  const host = request.headers.get("host") ?? "";
  const tenant = resolveTenant(host, request.nextUrl.pathname);

  if (!tenant) {
    return NextResponse.rewrite(new URL("/not-found", request.url));
  }

  // Pass tenant context to the application via headers
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-tenant-slug", tenant.slug);
  requestHeaders.set("x-tenant-id", tenant.id);

  return NextResponse.next({
    request: { headers: requestHeaders },
  });
}

type Tenant = { slug: string; id: string };

function resolveTenant(host: string, pathname: string): Tenant | null {
  // Subdomain strategy
  const baseDomain = process.env.BASE_DOMAIN ?? "yourapp.com";
  const subdomain = host.replace(`.${baseDomain}`, "").split(":")[0];

  if (subdomain && subdomain !== "www" && subdomain !== baseDomain) {
    return lookupTenantBySlug(subdomain);
  }

  // Path prefix fallback
  const pathSegments = pathname.split("/").filter(Boolean);
  if (pathSegments.length > 0) {
    return lookupTenantBySlug(pathSegments[0]);
  }

  return null;
}

function lookupTenantBySlug(slug: string): Tenant | null {
  // In production, this hits a cache (KV store, Redis, or in-memory LRU).
  // Middleware runs on every request, so this must be fast.
  // Avoid hitting the primary database here.
  return TENANT_CACHE.get(slug) ?? null;
}

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

A few things worth noting. The x-tenant-slug header approach is pragmatic: it makes tenant context available to all server components and route handlers without prop drilling. You could also use cookies, but headers are simpler for read-only context that should not persist across redirects.

The tenant lookup in middleware needs to be fast. Middleware runs on every request, including static asset requests if your matcher is not precise. A stale-while-revalidate cache pattern works well: serve from cache immediately, refresh in the background.

Reading Tenant Context in Server Components

With the tenant identifier flowing through headers, any server component or route handler can read it:

// lib/tenant.ts
import { headers } from "next/headers";

export async function getTenant() {
  const headerStore = await headers();
  const slug = headerStore.get("x-tenant-slug");
  const id = headerStore.get("x-tenant-id");

  if (!slug || !id) {
    throw new Error("Tenant context missing. Is middleware configured?");
  }

  return { slug, id };
}

This function is the single entry point for tenant context in server components. Every data fetch, every theme lookup, every permission check passes through it.

Per-Tenant Theming

Theming in a multi-tenant app breaks into two problems: how to store theme configuration, and how to apply it.

Store the theme as a JSON column on the tenant table. Keep it simple: primary color, logo URL, font family, and a handful of semantic tokens.

// types/tenant-theme.ts
export interface TenantTheme {
  primaryColor: string;
  primaryForeground: string;
  logoUrl: string | null;
  fontFamily: string;
  borderRadius: string;
}

const DEFAULT_THEME: TenantTheme = {
  primaryColor: "#2563eb",
  primaryForeground: "#ffffff",
  logoUrl: null,
  fontFamily: "Inter, system-ui, sans-serif",
  borderRadius: "0.5rem",
};

Apply the theme using CSS custom properties injected from a server component. This avoids shipping theme logic to the client bundle:

// components/tenant-theme-provider.tsx
import { getTenant } from "@/lib/tenant";
import { fetchTenantTheme } from "@/lib/theme";

export async function TenantThemeProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const tenant = await getTenant();
  const theme = await fetchTenantTheme(tenant.id);

  const cssVariables = {
    "--color-primary": theme.primaryColor,
    "--color-primary-foreground": theme.primaryForeground,
    "--font-family": theme.fontFamily,
    "--border-radius": theme.borderRadius,
  } as React.CSSProperties;

  return <div style={cssVariables}>{children}</div>;
}

In your root layout:

// app/layout.tsx
import { TenantThemeProvider } from "@/components/tenant-theme-provider";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <TenantThemeProvider>{children}</TenantThemeProvider>
      </body>
    </html>
  );
}

Your CSS then references these variables:

.btn-primary {
  background-color: var(--color-primary);
  color: var(--color-primary-foreground);
  border-radius: var(--border-radius);
  font-family: var(--font-family);
}

This pattern is efficient because the theme resolution happens on the server. No flash of unstyled content, no client-side JavaScript to swap colors. The CSS variables are inlined into the HTML, so the browser applies them on first paint.

Database Isolation Patterns

This is where multi-tenancy gets serious. The wrong choice here creates security vulnerabilities, performance bottlenecks, or operational nightmares. There are three established patterns.

Shared Schema with Row-Level Security (RLS)

All tenants share the same tables. Every table has a tenant_id column, and every query filters by it. PostgreSQL RLS enforces this at the database level so a missing WHERE clause cannot leak data.

-- Enable RLS on the projects table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Policy: users can only see rows matching their tenant
CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

On the application side, set the tenant context at the start of each request:

// lib/db.ts
import { Pool } from "pg";
import { getTenant } from "@/lib/tenant";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function withTenantContext<T>(
  fn: (client: pg.PoolClient) => Promise<T>
): Promise<T> {
  const tenant = await getTenant();
  const client = await pool.connect();

  try {
    await client.query("SELECT set_config('app.tenant_id', $1, true)", [
      tenant.id,
    ]);
    return await fn(client);
  } finally {
    client.release();
  }
}

When to use this. Most SaaS products with fewer than a few hundred tenants. Operationally simple: one database to back up, migrate, and monitor. RLS gives you defense in depth, so a bug in application code does not expose another tenant’s data.

Tradeoffs. Noisy neighbor risk (one tenant running heavy queries affects everyone). Index tuning becomes harder because all tenants’ data is in the same tables. Migrations affect all tenants simultaneously.

Schema-Per-Tenant

Each tenant gets a separate PostgreSQL schema within the same database. Tables are identical in structure but physically separated.

export async function withTenantSchema<T>(
  fn: (client: pg.PoolClient) => Promise<T>
): Promise<T> {
  const tenant = await getTenant();
  const schema = `tenant_${tenant.slug}`;
  const client = await pool.connect();

  try {
    await client.query(`SET search_path TO ${schema}, public`);
    return await fn(client);
  } finally {
    await client.query("RESET search_path");
    client.release();
  }
}

When to use this. When tenants have different data volumes and you want better isolation without running separate databases. Useful when some tenants need custom schema extensions.

Tradeoffs. Migrations must run against every schema, which can be slow with hundreds of tenants. Connection pooling gets tricky because search_path is connection-level state. PgBouncer in transaction mode resets session state between transactions, which can silently break this pattern.

Database-Per-Tenant

Each tenant gets a completely separate database (or even a separate database server). Maximum isolation, maximum operational overhead.

// lib/tenant-db.ts
import { Pool } from "pg";

const poolCache = new Map<string, Pool>();

export function getTenantPool(tenantId: string): Pool {
  if (!poolCache.has(tenantId)) {
    const connectionString = resolveTenantConnectionString(tenantId);
    poolCache.set(tenantId, new Pool({ connectionString, max: 5 }));
  }
  return poolCache.get(tenantId)!;
}

When to use this. Enterprise customers with strict data residency requirements, compliance mandates (SOC 2, HIPAA), or when tenants pay enough to justify the operational cost. Also useful when individual tenant databases might live in different regions.

Tradeoffs. Connection pool proliferation is the main risk. Each pool holds connections, and with hundreds of tenants you can exhaust memory and file descriptors. Migrations become a deployment pipeline of their own. Cross-tenant analytics requires a separate data warehouse.

For most teams starting out, shared schema with RLS is the right default. It is the simplest to operate and the hardest to accidentally leak data from.

Caching with Tenant Context

Caching in a multi-tenant system requires one absolute rule: every cache key must include the tenant identifier. Skip this and you will serve Tenant A’s data to Tenant B.

Next.js has built-in caching via fetch and unstable_cache. Both need tenant-scoped keys:

import { unstable_cache } from "next/cache";
import { getTenant } from "@/lib/tenant";

export async function getProjects() {
  const tenant = await getTenant();

  const cached = unstable_cache(
    async () => {
      return db.project.findMany({ where: { tenantId: tenant.id } });
    },
    [`projects-${tenant.id}`],
    { tags: [`tenant-${tenant.id}`], revalidate: 60 }
  );

  return cached();
}

The tags array is useful for targeted invalidation. When a tenant updates their data, you can revalidate just their cache entries:

import { revalidateTag } from "next/cache";

export async function updateProject(projectId: string, data: UpdateProjectInput) {
  const tenant = await getTenant();
  await db.project.update({
    where: { id: projectId, tenantId: tenant.id },
    data,
  });
  revalidateTag(`tenant-${tenant.id}`);
}

If you use Redis for caching, prefix every key with the tenant ID: tenant:${tenantId}:projects:list. This also makes it possible to flush a single tenant’s cache without affecting others.

One subtle issue with Next.js: the Data Cache is shared across all requests in a deployment. If you use generateStaticParams to pre-render tenant pages, make sure the static paths include the tenant context. Otherwise you will serve cached pages from the wrong tenant.

Authentication in Multi-Tenant Setups

Authentication adds a layer because users can belong to multiple tenants. The standard model is: a user authenticates once, then selects or is routed to a specific tenant. The session or JWT must include both the user ID and the current tenant ID.

// lib/auth.ts
import { jwtVerify } from "jose";
import { getTenant } from "@/lib/tenant";

interface SessionPayload {
  userId: string;
  tenants: Array<{ tenantId: string; role: string }>;
}

export async function getSession(): Promise<{
  userId: string;
  tenantId: string;
  role: string;
} | null> {
  const token = cookies().get("session")?.value;
  if (!token) return null;

  const { payload } = await jwtVerify(
    token,
    new TextEncoder().encode(process.env.JWT_SECRET)
  ) as { payload: SessionPayload };

  const tenant = await getTenant();
  const membership = payload.tenants.find(
    (t) => t.tenantId === tenant.id
  );

  if (!membership) return null;

  return {
    userId: payload.userId,
    tenantId: tenant.id,
    role: membership.role,
  };
}

The key detail is the membership check: even if the user has a valid session, they must have an active membership in the tenant they are trying to access. Without this, a user who knows another tenant’s subdomain could access it.

For providers like Clerk, Auth.js, or WorkOS, the concept is the same but the implementation differs. Clerk has built-in organization support that maps directly to tenants. Auth.js requires you to manage the tenant-user relationship yourself.

The login flow typically works like this: the user visits acme.yourapp.com/login, authenticates, and the backend verifies they have a membership in the acme tenant. If they do not, they see an error, not a dashboard.

Deployment Strategies

Multi-tenant Next.js applications generally deploy as a single application instance (or set of instances behind a load balancer). All tenants share the same build artifact. This is the whole point of multi-tenancy: one codebase, one deployment.

For Vercel deployments, wildcard subdomains require configuring the domain at the project level. Vercel handles wildcard SSL automatically. Custom domains use the Vercel Domains API to provision on-the-fly.

For self-hosted deployments on something like AWS or Fly.io, you need:

  1. A reverse proxy (Caddy, nginx, or Traefik) that terminates TLS for wildcard and custom domains
  2. The proxy forwards the Host header to your Next.js application
  3. Your middleware resolves the tenant from that header

Caddy is particularly good here because it handles automatic TLS provisioning for custom domains via the ACME protocol:

*.yourapp.com {
  reverse_proxy localhost:3000
}

For custom domains, you need Caddy’s on_demand_tls feature, which provisions certificates when the first request arrives for a new domain.

Putting It Together

The full architecture looks like this:

  1. Request arrives at a subdomain or custom domain
  2. Middleware resolves the tenant from the hostname, sets headers
  3. Root layout reads tenant context, applies theme via CSS variables
  4. Server components read tenant context, fetch tenant-scoped data
  5. Database queries are scoped by RLS or schema isolation
  6. Cache keys include the tenant identifier
  7. Authentication verifies both user identity and tenant membership

Each of these layers reinforces the others. If middleware fails to resolve a tenant, server components throw. If RLS is configured, a missing tenant_id filter still cannot leak data. If cache keys include the tenant, a bug in one layer does not expose data from another.

The most common mistake is treating multi-tenancy as something you can bolt on later. Tenant context needs to flow through every layer from the first request. Starting with the patterns in this article, even in a simple form, saves significant refactoring when you need to add custom domains or enterprise isolation tiers down the road.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.