DevStacked
ArchitectureSeptember 19, 202629 min read

Multi-Tenant Architecture in Next.js 16 (2026)

Every SaaS product hits the same wall eventually: you built it for one company, and now you need it to work for a hundred — each with their own users, their own data, and their own little corner of the app, like acme.yourapp.com. Bolt that on badly and you get the nightmare scenario every backend developer fears: one bug in a WHERE clause, and Company A's customer list shows up in Company B's dashboard.

That's the entire problem multi-tenant architecture solves. It's not one trick — it's a set of decisions about routing, data isolation, and access control that, done right, make "which company does this user belong to?" a question your app can never get wrong, even if a developer forgets to ask it.

By the end of this guide, you'll have a working multi-tenant Next.js 16 app: subdomain-based routing through proxy.ts, a tenant data model backed by Postgres Row Level Security so isolation is enforced by the database — not just your application code — and a path to deploying it on Vercel with wildcard domains.


Quick Answer: What Is Multi-Tenant Architecture?

Multi-tenant architecture is a software design where a single application instance and (usually) a single database serve multiple independent customers — called tenants — while keeping each tenant's data, users, and configuration completely isolated from every other tenant. Instead of deploying a separate copy of your app per customer, one codebase and one deployment serve everyone, and the app itself decides which tenant a request belongs to and what data that tenant is allowed to see.

Think of it like an apartment building: one building (your app), many separate units (tenants), each with its own locked door (data isolation) — but the plumbing, the elevator, and the foundation (your codebase and infrastructure) are shared.


Single-Tenant vs. Multi-Tenant: The Difference That Matters

Single-TenantMulti-Tenant
DeploymentOne app + one database per customerOne app (usually one database) for all customers
Infrastructure costScales linearly with customersShared, scales with usage not customer count
Data isolationPhysical (separate databases)Logical (enforced in code and/or the database)
Updates/bug fixesDeploy to every customer separatelyDeploy once, everyone gets it
Customization per customerEasy — each customer has an isolated deployment/configurationHarder — needs a plan/feature-flag system
Best forEnterprise clients needing full data isolation or complianceSaaS products with many small-to-mid customers

Many collaboration and SaaS platforms expose workspace/organization concepts that follow the multi-tenant model. You don't get your own copy of Slack's servers when you sign up; you get a workspace, which is just a tenant inside their shared infrastructure.

💡 Tip: "Tenant" is deliberately generic — in your app it might be called a workspace, organization, team, company, or account. The underlying pattern is identical regardless of what you call it in your UI.


Why This Is Tricky

Multi-tenancy isn't hard because the concept is complicated — it's hard because the cost of getting it wrong is severe (a data leak between customers), and there are genuinely several valid ways to build it, each with real tradeoffs. Before writing any code, you need to answer two separate questions:

  1. How do I identify which tenant a request belongs to? (subdomain, custom domain, or URL path)
  2. How do I keep each tenant's data isolated? (separate databases, separate schemas, or one shared table set with a tenant_id column)

Get the first one wrong and tenants can't reach the right version of your app. Get the second one wrong and — worst case — tenants can see each other's data. We'll cover both, and this guide focuses on the combination most SaaS products actually ship with: subdomain-based routing plus a shared database with row-level isolation, because it's the cheapest to run and the fastest to build on, while still giving you database-enforced security.


The Three Data Isolation Models

ModelHow it worksIsolation strengthCost & complexity
Database-per-tenantEvery tenant gets a fully separate databaseStrongest — physical separationHighest — migrations, backups, and connections multiply per tenant
Schema-per-tenantOne database, one Postgres schema per tenantStrong — separate tables per tenantMedium — still need to manage N schemas, but one database to back up
Shared tables + tenant_idOne database, one set of tables, every row tagged with a tenant_idDepends entirely on enforcement — weak if only in app code, strong with Postgres Row Level SecurityLowest — one schema, one migration, scales to thousands of tenants easily

For most SaaS products — and everything in this guide — shared tables with a tenant_id column, enforced by Postgres Row Level Security (RLS), is the right default. It's the cheapest to operate, the easiest to migrate, and — critically — RLS means isolation is enforced by the database itself, not just by remembering to add .eq("tenant_id", ...) to every query. Reach for schema-per-tenant or database-per-tenant only when a specific customer has a compliance requirement (like data residency) that a shared database genuinely can't satisfy.

Related: Supabase RLS Policy Generator — generates the exact kind of tenant-scoped policies this guide writes by hand, in seconds.


Three Ways to Identify a Tenant

StrategyExample URLProsCons
Subdomainacme.yourapp.comClean URLs, easy to brand, works great with wildcard SSLNeeds wildcard DNS + nameserver setup
Custom domainapp.acme.comBest for enterprise/white-label, tenant owns their domainMost setup work — DNS verification, SSL provisioning per domain
Path-basedyourapp.com/acme/dashboardZero DNS config, works anywhere immediatelyTenant is visible and editable in every URL, no per-tenant branding

This guide builds subdomain-based routing, since it's the sweet spot most SaaS products land on — better UX and branding than path-based, far less setup work than per-tenant custom domains. We'll touch on custom domains near the end for when you're ready to offer that too.


What We're Building

  • Next.js 16 with the App Router
  • TypeScript in strict mode
  • Supabase (Postgres) for the database, using Row Level Security for tenant isolation
  • Tailwind CSS for the (minimal) UI

By the end, visiting acme.localhost:3000 will resolve to Acme's tenant context, acme.yourapp.com will do the same in production, and every database query for tenant data will be protected by RLS — so even a bug in your own code can't leak one tenant's rows to another.

tenant1.yourapp.com/dashboard
        
   proxy.ts resolves "tenant1"  looks up tenant in DB
        
   Tenant ID attached to the request as a header
        
   Server Component reads the header, fetches tenant-scoped data
        
   Postgres RLS double-checks: is this row actually this tenant's?

Step 1: Project Setup

If you're starting fresh:

npx create-next-app@latest multi-tenant-app

Choose TypeScript, Tailwind CSS, App Router, and the src/ directory if you prefer it (this guide omits src/ for shorter paths). You'll also need Supabase:

npm install @supabase/supabase-js @supabase/ssr

Add your environment variables:

# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_publishable_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key

# The root domain your app is served from. In development this keeps
# its port (localhost:3000); in production it's your bare domain.
NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000

⚠️ Common Mistake: SUPABASE_SERVICE_ROLE_KEY must never be prefixed with NEXT_PUBLIC_. It bypasses every Row Level Security policy in your database — treat it exactly like a database root password, server-only, never shipped to the browser.


Step 2: Resolve the Tenant in proxy.ts

In Next.js 16, the file previously called middleware.ts was renamed to proxy.ts — same job, same file conventions, just a new name and a change in default runtime (it now runs on the Node.js runtime by default, which matters here since we're making a database call on every request).

💡 Think of proxy.ts like a receptionist at the front desk of your building. Every visitor (request) talks to the receptionist first. The receptionist checks which company (tenant) they're visiting based on which door they walked through (the subdomain), confirms that company actually exists in the building directory, and then tells the elevator (your app) exactly where to send them — before the visitor ever reaches an office.

First, a small server-only lookup function:

// lib/supabase/service-client.ts
import "server-only";
import { createClient } from "@supabase/supabase-js";

// Service-role client — bypasses RLS, has no user session attached.
// Only ever call this for trusted, server-only reads like tenant lookups.
// Never import this into a Client Component.
export const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);
// lib/tenant/get-tenant-by-subdomain.ts
import "server-only";
import { supabaseAdmin } from "@/lib/supabase/service-client";

export type TenantSummary = {
  id: string;
  slug: string;
};

export async function getTenantBySubdomain(
  subdomain: string
): Promise<TenantSummary | null> {
  const { data, error } = await supabaseAdmin
    .from("tenants")
    .select("id, slug")
    .eq("slug", subdomain)
    .maybeSingle();

  if (error || !data) return null;
  return data;
}

If you already have Supabase auth wired up (see our Supabase Auth vs Firebase Auth guide if not), you likely already have these two files for keeping the auth session refreshed on every request:

// lib/supabase/proxy.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function updateSession(request: NextRequest) {
    let supabaseResponse = NextResponse.next({ request });

    const supabase = createServerClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
        {
            cookies: {
                getAll() {
                    return request.cookies.getAll();
                },

                setAll(cookiesToSet, headers) {
                    cookiesToSet.forEach(({ name, value }) =>
                        request.cookies.set(name, value)
                    );

                    supabaseResponse = NextResponse.next({
                        request,
                    });

                    cookiesToSet.forEach(({ name, value, options }) =>
                        supabaseResponse.cookies.set(name, value, options)
                    );
                    Object.entries(headers).forEach(([key, value]) =>
                        supabaseResponse.headers.set(key, value)
                    );
                },
            },
        }
    );

    await supabase.auth.getClaims();

    return supabaseResponse;
}
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },

        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Ignore if called from Server Component
          }
        },
      },
    }
  );
}

⚠️ Don't skip this if you have authentication. Tenant resolution and session refresh are two separate concerns, but they both have to happen in the same proxy.ts run. If you only resolve the tenant and never call updateSession, Supabase's access token is never refreshed on the server, and signed-in users get logged out once it expires — even though nothing about tenant routing looks broken.

Now the proxy itself, composing both:

// proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { updateSession } from "@/lib/supabase/proxy";
import { getTenantBySubdomain } from "@/lib/tenant/get-tenant-by-subdomain";

// Keep the port for building an absolute redirect URL later; strip it
// only when comparing hostnames.
const ROOT_DOMAIN_ENV = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "localhost:3000";
const ROOT_DOMAIN = ROOT_DOMAIN_ENV.split(":")[0];

// Subdomains that belong to your main app, not a tenant.
const RESERVED_SUBDOMAINS = new Set(["www", "app", "admin", "api"]);

export async function proxy(request: NextRequest) {
  // Refresh the Supabase auth session first. This may rewrite Set-Cookie
  // headers if the access token needed rotating — we copy those onto
  // whatever response we ultimately return below.
  const supabaseResponse = await updateSession(request);

  const host = (request.headers.get("host") ?? "").split(":")[0];
  const subdomain = getSubdomain(host);

  // No subdomain, or a reserved one — this is the marketing site or the
  // main app shell, not a tenant. Nothing tenant-related to attach.
  if (!subdomain || RESERVED_SUBDOMAINS.has(subdomain)) {
    return supabaseResponse;
  }

  const tenant = await getTenantBySubdomain(subdomain);

  if (!tenant) {
    return redirectToTenantNotFound(request, subdomain);
  }

  // Strip any inbound tenant headers so a client can never forge them —
  // only this proxy is allowed to set x-tenant-*.
  const requestHeaders = new Headers(request.headers);
  requestHeaders.delete("x-tenant-id");
  requestHeaders.delete("x-tenant-slug");
  requestHeaders.set("x-tenant-id", tenant.id);
  requestHeaders.set("x-tenant-slug", tenant.slug);

  // Rebuild the response so the tenant headers ride along on the
  // *request* (this is what makes them readable via headers() in a
  // Server Component), then carry over any refreshed auth cookies.
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  supabaseResponse.cookies.getAll().forEach((cookie) => {
    response.cookies.set(cookie);
  });

  return response;
}

// Redirects to the ROOT domain, not the unknown tenant's subdomain —
// redirecting back to the same bad host would just re-trigger this
// same lookup and loop forever.
//
// Built as a brand-new URL rather than mutating a clone of request.url.
// Cloning request.url first (new URL(request.url)) means the object
// starts out with the tenant subdomain already baked into it, and
// reassigning .host on that object is not reliable for fully replacing
// an existing hostname — in practice it can end up only changing the
// port and leaving the old subdomain in place (e.g. producing
// "unknowndomain.localhost:3000" instead of "localhost:3000"), which
// silently recreates the exact loop this function exists to prevent.
// Constructing the URL from a plain template string sidesteps that
// entirely, since there's no stale hostname left for anything to retain.
function redirectToTenantNotFound(request: NextRequest, subdomain: string) {
  const protocol = request.nextUrl.protocol; // "http:" or "https:"
  const notFoundUrl = new URL(`${protocol}//${ROOT_DOMAIN_ENV}/tenant-not-found`);
  notFoundUrl.searchParams.set("tenant", subdomain);
  return NextResponse.redirect(notFoundUrl);
}

function getSubdomain(host: string): string | null {
  if (host === ROOT_DOMAIN || host === `www.${ROOT_DOMAIN}`) return null;
  if (!host.endsWith(`.${ROOT_DOMAIN}`)) return null;

  return host.slice(0, -(ROOT_DOMAIN.length + 1));
}

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

What's happening here:

  • updateSession(request) runs first and unconditionally — auth refresh shouldn't depend on whether the host happens to be a tenant subdomain.
  • redirectToTenantNotFound() builds a brand-new URL pointed at ROOT_DOMAIN_ENV (your real app domain) instead of the unresolved tenant subdomain. The next time proxy runs against that new URL, getSubdomain() returns null for the root domain and the request just passes through — no loop. This has to be a fresh URL, not a mutated clone of the incoming request's URL — cloning request.url first means the tenant subdomain is already baked into the object, and reassigning .host on it is not reliable for fully replacing that hostname (in practice it can end up changing only the port while the old subdomain silently survives, e.g. unknowndomain.localhost:3000 instead of localhost:3000), which just recreates the same loop this function exists to prevent.
  • The ?tenant= query param survives the redirect, so your /tenant-not-found page can show which subdomain didn't resolve.
  • We only build requestHeaders (and delete any inbound x-tenant-* values) once we know a real tenant was found — there's nothing to strip or attach on the reserved/no-subdomain path, since supabaseResponse is returned as-is.
  • supabaseResponse.cookies.getAll() returns whatever refreshed auth cookies Supabase's client queued up; copying them onto the new tenant-aware response means neither concern silently overwrites the other.

A minimal page for the redirect target:

// app/tenant-not-found/page.tsx
interface PageProps {
  searchParams: Promise<{ tenant?: string }>;
}

export default async function TenantNotFoundPage({ searchParams }: PageProps) {
  const { tenant } = await searchParams;

  return (
    <div className="mx-auto max-w-md space-y-3 p-8 text-center">
      <h1 className="text-2xl font-semibold">Workspace not found</h1>
      <p className="text-sm text-muted-foreground">
        {tenant
          ? `We couldn't find a workspace at "${tenant}".`
          : "That workspace doesn't exist."}
      </p>
    </div>
  );
}

⚠️ Common Mistake: Running your tenant lookup on the Edge runtime by explicitly setting runtime: 'edge'. Don't — proxy.ts in Next.js 16 runs on the Node.js runtime by default (and throws if you try to override it), which is exactly what you want here, since a database client typically needs Node.js APIs that aren't available on the Edge runtime.

💡 Tip: For a small application, this direct indexed lookup is straightforward. At higher traffic, cache the subdomain → tenant mapping in Redis/KV/Edge Config or another low-latency store so proxy doesn't hit Postgres on every request.


Step 3: Testing Subdomains Locally

You don't need to edit your /etc/hosts file for this. Modern browsers (Chrome, Firefox, Safari) automatically resolve any *.localhost address to 127.0.0.1 — it's a reserved TLD, not a Next.js feature. So once your dev server is running:

npm run dev

Visiting http://acme.localhost:3000 in your browser just works, as long as NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000 is set in .env.local and a tenant with slug = 'acme' exists in your database.

💡 Tip: If a subdomain isn't resolving locally, it's almost always a browser/network quirk (some corporate VPNs or older browsers don't support .localhost resolution) rather than a bug in your code — try a different browser before debugging proxy.ts.


Step 4: Model Tenants and Isolation in Postgres

This is the part that actually keeps tenants' data apart. Run this in the Supabase SQL editor.

-- Core tenant table. The slug doubles as a DNS label (acme.yourapp.com),
-- so its constraint is stricter than a generic "url-safe string" check:
-- no leading/trailing hyphens, and capped at 63 characters (the DNS
-- label limit), on top of lowercase alphanumerics and hyphens.
create table public.tenants (
  id uuid primary key default gen_random_uuid(),
  slug text unique not null check (
    char_length(slug) between 1 and 63
    and slug ~ '^[a-z0-9-]+$'
    and slug !~ '(^-|-$)'
  ),
  name text not null,
  plan text not null default 'free',
  created_at timestamptz not null default now()
);

-- Who belongs to which tenant, and with what role
create table public.memberships (
  user_id uuid not null references auth.users(id) on delete cascade,
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  role text not null default 'member' check (role in ('owner', 'admin', 'member')),
  created_at timestamptz not null default now(),
  primary key (user_id, tenant_id)
);

-- An example tenant-scoped resource  every real table in your app
-- (invoices, documents, tasks, whatever) follows this same shape.
create table public.projects (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  name text not null,
  created_at timestamptz not null default now()
);

-- The composite primary key (user_id, tenant_id) already gives Postgres
-- a btree index usable for "where user_id = ?" lookups, since a
-- multi-column index supports equality search on a leading prefix of
-- its columns  a separate single-column index on user_id would just
-- be a redundant duplicate. What that composite index can't serve
-- efficiently is "where tenant_id = ?" on its own, since tenant_id
-- isn't the leading column  that's the index actually worth adding.
create index idx_memberships_tenant_id on public.memberships (tenant_id);
create index idx_projects_tenant_id on public.projects (tenant_id);

alter table public.tenants enable row level security;
alter table public.memberships enable row level security;
alter table public.projects enable row level security;

What's happening here: tenants is your list of customers. memberships is the join table linking Supabase's built-in auth.users to tenants — one user can belong to multiple tenants (think: a consultant working with several client workspaces). projects is a stand-in for any real, tenant-owned data table — invoices, documents, tasks, whatever your app actually stores.

Write the RLS Policies

A tenant_id column on a table means nothing on its own — it's just data. Row Level Security is what turns that column into an actual security boundary, enforced by Postgres itself rather than trusted to every query you (or a future teammate) ever write.

-- Security-definer helper: returns every tenant_id the current user
-- belongs to. Wrapping it this way avoids the classic recursive-RLS
-- trap (a policy on "projects" querying "memberships", which has its
-- own RLS that would otherwise re-trigger the same check).
--
-- It lives in a dedicated "private" schema rather than "public" 
-- Supabase's Data API (PostgREST) only exposes schemas you've explicitly
-- opted into (public by default), so putting security-definer functions
-- in "private" keeps them uncallable directly over the REST/RPC API.
-- A security-definer function sitting in "public" is callable by anyone
-- who can reach your API, running with the *creator's* privileges —
-- that's a real privilege-escalation surface if it isn't locked down.
create schema if not exists private;

create or replace function private.user_tenant_ids()
returns setof uuid
language sql
security definer
stable
set search_path = ''
as $$
  select tenant_id
  from public.memberships
  where user_id = (select auth.uid())
$$;

-- Postgres grants EXECUTE on every new function to PUBLIC by default —
-- revoke it explicitly, then grant only to the role that actually needs it.
revoke execute on function private.user_tenant_ids() from public;
revoke execute on function private.user_tenant_ids() from anon;
grant usage on schema private to authenticated;
grant execute on function private.user_tenant_ids() to authenticated;

-- Members can see their own membership rows
create policy "memberships_select_own"
on public.memberships
for select
to authenticated
using ( user_id = (select auth.uid()) );

-- Members can see the tenants they belong to
create policy "tenants_select_member"
on public.tenants
for select
to authenticated
using ( id in (select private.user_tenant_ids()) );

-- Members can only see, create, edit, or delete projects that belong
-- to a tenant they're actually part of
create policy "projects_select_tenant_member"
on public.projects
for select
to authenticated
using ( tenant_id in (select private.user_tenant_ids()) );

create policy "projects_insert_tenant_member"
on public.projects
for insert
to authenticated
with check ( tenant_id in (select private.user_tenant_ids()) );

create policy "projects_update_tenant_member"
on public.projects
for update
to authenticated
using ( tenant_id in (select private.user_tenant_ids()) )
with check ( tenant_id in (select private.user_tenant_ids()) );

create policy "projects_delete_tenant_member"
on public.projects
for delete
to authenticated
using ( tenant_id in (select private.user_tenant_ids()) );

What's happening here: user_tenant_ids() is a security definer function — it runs with elevated privileges internally, which lets it read memberships (bypassing that table's own RLS just for this lookup) without creating a circular dependency. set search_path = '' forces every identifier inside the function body to be fully schema-qualified (notice public.memberships, not just memberships) — without this, a malicious or accidental object created earlier in the search path could get resolved instead of the table you actually meant, which is exactly the class of attack Supabase's own linter flags as "Function Search Path Mutable." The revoke/grant pair closes the other half of the gap: by default Postgres lets any role call a newly created function, so without the explicit revoke, anon (unauthenticated requests) could call it too.

Every policy on projects then just asks "is this row's tenant_id one this user belongs to?" Wrapping auth.uid() in (select ...) lets Postgres cache the result once per statement instead of re-evaluating it for every row — a meaningful performance win once your tables have real data in them.

💡 Tip: Notice there's no policy granting using (true) anywhere. Every single operation is explicitly scoped to tenant membership — this is deliberate. A missing policy means Postgres denies access by default, which is exactly the fail-safe direction you want security to fail in.

⚠️ Common Mistake: Putting a security definer helper function in the public schema "because that's where everything else is." If a schema is exposed through Supabase's Data API settings (public is, by default), every function in it is a potential RPC endpoint — private (or any schema you deliberately keep out of the exposed-schemas list) is the safer default for anything that runs with elevated privileges.

Related: Supabase Auth vs Firebase Auth in 2026 if you haven't wired up authentication yet.


Step 5: Read the Tenant in Server Components

Now that proxy.ts attaches x-tenant-id to every request, build a small Data Access Layer function that reads it — this is the single place the rest of your app goes to ask "which tenant is this?"

// lib/tenant/get-current-tenant.ts
import "server-only";
import { cache } from "react";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { supabaseAdmin } from "@/lib/supabase/service-client";

export type Tenant = {
  id: string;
  slug: string;
  name: string;
  plan: string;
};

export const getCurrentTenant = cache(async (): Promise<Tenant> => {
  const headersList = await headers();
  const tenantId = headersList.get("x-tenant-id");

  if (!tenantId) {
    // No tenant header means this request never went through a tenant
    // subdomain — proxy.ts only sets it for tenant*.yourapp.com traffic.
    redirect("/");
  }

  const { data: tenant, error } = await supabaseAdmin
    .from("tenants")
    .select("id, slug, name, plan")
    .eq("id", tenantId)
    .maybeSingle();

  if (error || !tenant) {
    redirect("/tenant-not-found");
  }

  return tenant;
});

What's happening here: headers() is async in Next.js 16, so it has to be awaited. Wrapping the whole function in React's cache() means no matter how many components on the same page call getCurrentTenant(), the actual header read and database lookup only run once per request.

Next, a helper that also confirms the signed-in user actually belongs to this tenant — not just that the tenant exists:

// lib/tenant/require-tenant-member.ts
import "server-only";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { getCurrentTenant } from "./get-current-tenant";

export async function requireTenantMember() {
  const tenant = await getCurrentTenant();
  const supabase = await createClient();

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    redirect(`/login?tenant=${tenant.slug}`);
  }

  // This query runs through the cookie-based client, so RLS applies —
  // it can only ever return a row for the signed-in user themselves.
  const { data: membership } = await supabase
    .from("memberships")
    .select("role")
    .eq("tenant_id", tenant.id)
    .eq("user_id", user.id)
    .maybeSingle();

  if (!membership) {
    redirect("/not-a-member");
  }

  return { tenant, user, role: membership.role };
}

Use it in a page:

// app/dashboard/page.tsx
import { requireTenantMember } from "@/lib/tenant/require-tenant-member";
import { createClient } from "@/lib/supabase/server";

export default async function DashboardPage() {
  const { tenant, user } = await requireTenantMember();
  const supabase = await createClient();

  // The explicit tenant_id filter selects the active tenant represented
  // by this request. RLS provides the database-level safety net that
  // prevents the signed-in user from accessing tenants they don't belong to.
  const { data: projects } = await supabase
    .from("projects")
    .select("id, name, created_at")
    .eq("tenant_id", tenant.id)
    .order("created_at", { ascending: false });

  return (
    <div className="mx-auto max-w-2xl space-y-6 p-8">
      <div>
        <h1 className="text-2xl font-semibold">{tenant.name}</h1>
        <p className="text-sm text-muted-foreground">Signed in as {user.email}</p>
      </div>

      <ul className="space-y-2">
        {projects?.map((project) => (
          <li key={project.id} className="rounded-lg border p-3">
            {project.name}
          </li>
        ))}
      </ul>
    </div>
  );
}

What's happening here: notice the .eq("tenant_id", tenant.id) filter is still there in the app code, even though RLS would block cross-tenant rows either way. This is defense in depth — the query filter makes the intent obvious to any developer reading the code, and RLS is the safety net that catches the case where someone forgets it.

Writing Tenant-Scoped Data with a Server Action

// actions/create-project.ts
"use server";

import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { requireTenantMember } from "@/lib/tenant/require-tenant-member";

export async function createProject(formData: FormData) {
  const { tenant } = await requireTenantMember();
  const supabase = await createClient();

  const name = (formData.get("name") as string | null)?.trim();

  if (!name) {
    return { error: "Project name is required." };
  }

  const { error } = await supabase
    .from("projects")
    .insert({ name, tenant_id: tenant.id });

  if (error) {
    return { error: error.message };
  }

  revalidatePath("/dashboard");
  return { success: true };
}

What's happening here: requireTenantMember() runs first, so an unauthenticated or non-member request never reaches the database at all. Even so, the insert explicitly sets tenant_id: tenant.id rather than trusting a value from the form — never let tenant identity come from client-submitted data, always derive it server-side from the resolved tenant context.

⚠️ Common Mistake: Accepting a tenantId field from formData or a request body and trusting it. The tenant a user is allowed to write to should always come from getCurrentTenant() / the resolved request context — never from anything the client sent.

Related: Role-Based Access Control (RBAC) in Next.js 16 pairs naturally with this — once you have tenants, you'll usually want owner/admin/member permissions within each tenant too, not just membership.


Step 6: Deploy Wildcard Domains on Vercel

To get acme.yourapp.com, beta-co.yourapp.com, and every future tenant's subdomain working automatically in production, you need a wildcard domain pointed at your Vercel project.

  1. Point your domain to Vercel's nameservers (ns1.vercel-dns.com and ns2.vercel-dns.com). Vercel needs to control your DNS to automatically issue SSL certificates for each new subdomain.
  2. In your Vercel project's Domains settings, add the apex domain: yourapp.com.
  3. Add a wildcard domain: *.yourapp.com.

Once that's done, any single-label subdomain — acme.yourapp.com, beta-co.yourapp.com — automatically resolves to your deployment, with Vercel issuing a certificate for each one on the fly. No per-tenant DNS work required on your end.

⚠️ A wildcard certificate for *.yourapp.com covers exactly one DNS label — it matches acme.yourapp.com but not a deeper subdomain like docs.acme.yourapp.com. That kind of nested subdomain would need its own certificate and DNS entry, and this guide's getSubdomain() parser doesn't handle it either (it would slice docs.acme.yourapp.com down to the single string "docs.acme", which isn't even a valid slug under the constraint from Step 4). If you need per-tenant sub-paths like that, treat it as a separate, deliberate feature — don't assume it falls out of wildcard support for free.

Update your production environment variable to match:

NEXT_PUBLIC_ROOT_DOMAIN=yourapp.com

💡 Tip: If tenants will ever be able to publish their own content or run their own code on your subdomains (not just view data you control), submit your domain to the Public Suffix List so browsers isolate cookies between tenant1.yourapp.com and tenant2.yourapp.com. This matters more for user-generated-content platforms than a typical internal-dashboard SaaS.

⚠️ Important: If you become a platform that gives mutually untrusted customers control over content or code on separate subdomains, investigate whether your domain qualifies for the PSL PRIVATE section. This changes browser cookie boundaries, so understand the consequences before applying.

What About Custom Domains?

Letting a tenant use their own domain (app.acme.com instead of acme.yourapp.com) is a common upsell for higher-tier plans, but it's meaningfully more setup: you provision the domain onto your Vercel project programmatically (via the Vercel SDK), verify the tenant actually owns it with a TXT record, and let Vercel auto-issue SSL once verified. The tenant-resolution logic in proxy.ts from Step 2 extends naturally — you'd just add a lookup by full hostname (domain_${hostname}) before falling back to subdomain parsing. Treat this as a Phase 2 feature; ship subdomains first.


Production Checklist

  • proxy.ts deletes inbound x-tenant-* headers before setting its own — never trust client-supplied tenant headers
  • An unresolved tenant subdomain redirects to the root domain, not the tenant's own (bad) host — otherwise it loops
  • proxy.ts refreshes the Supabase auth session on every request, not just tenant headers
  • Every tenant-owned table has a tenant_id column, a matching index, and RLS enabled
  • RLS policies exist for select, insert, update, and delete — not just select
  • No policy anywhere uses a bare using (true) unless a resource is genuinely meant to be public
  • Server Actions derive tenant_id from the resolved server-side tenant context, never from client-submitted form data
  • Reserved subdomains (www, app, admin, api) are excluded from tenant resolution
  • Tenant slugs are constrained as valid single-label DNS names (no leading/trailing hyphens, ≤63 characters)
  • Wildcard domain (*.yourapp.com) and apex domain are both added in Vercel, with nameservers delegated
  • anon/authenticated table grants are explicitly restricted to only the operations the app requires

Frequently Asked Questions

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

For most SaaS products, one shared database with a tenant_id column and Postgres Row Level Security is the right default — it's far cheaper to run and easier to migrate than managing N separate databases. Reach for database-per-tenant only when a specific customer has a hard compliance or data-residency requirement that a shared database genuinely can't satisfy.

Is subdomain-based or path-based multi-tenancy better for SEO?

Subdomains and paths are both crawlable and indexable — Google treats acme.yourapp.com and yourapp.com/acme as ranking similarly well on their own. The bigger SEO factor is usually whether tenant content is even meant to be publicly indexed at all (most SaaS dashboards shouldn't be); pick based on branding and UX, not a search-ranking difference.

Do I need Postgres RLS, or is filtering by tenant_id in my app code enough?

Filtering in app code works right up until someone forgets it in one query, and that single missed .eq("tenant_id", ...) is a cross-tenant data leak. RLS makes isolation the database's job, not a convention every developer has to remember on every query, forever — it's the difference between "isolation by discipline" and "isolation by design."

Can I test tenant subdomains locally without editing my hosts file?

Yes — *.localhost addresses (like acme.localhost:3000) resolve to 127.0.0.1 automatically in modern browsers, since .localhost is a reserved TLD. No /etc/hosts edits or extra tooling needed for local development.

How do I let a tenant use their own custom domain instead of a subdomain?

You provision their domain onto your Vercel project through the Vercel SDK, have them verify ownership with a TXT record, and Vercel auto-issues an SSL certificate once verified. Your proxy.ts tenant-resolution logic extends to check the full hostname against a "custom domains" lookup before falling back to subdomain parsing.

Does wildcard domain support work on every Vercel plan?

Wildcard domains require pointing your domain at Vercel's nameservers so Vercel can manage the DNS challenges needed for wildcard SSL certificates — the exact plan requirements and limits can change, so check Vercel's current multi-tenant platform docs before committing your DNS to a specific plan.


Wrapping Up

Multi-tenant architecture in Next.js 16 comes down to two layers working together: proxy.ts figures out which tenant a request belongs to and hands that context downstream as a header, while Postgres Row Level Security makes sure that context is actually enforced — not just trusted — every time data gets read or written. Get those two pieces right, and "which company does this row belong to?" stops being a question your application code has to get right on every single query; it becomes a guarantee your database makes for you.

From here, natural next steps are layering role-based permissions on top of tenant membership (owner vs. admin vs. member), building a tenant-scoped billing flow with Stripe subscriptions, and revisiting session vs. JWT if you want tenant context embedded directly in the session instead of resolved fresh on every request.

Continue Learning

Free Developer Tools

  • Supabase RLS Policy Generator — generate strict, tenant/team-scoped Row Level Security policies like the ones in this guide, including the auth.uid() performance wrapper and a security-definer helper function, without hand-writing SQL.

📦 Source Code: View on GitHub

Next.jsMulti-TenantSaaSProxySupabaseRow Level SecurityTypeScriptApp Router
Share On