0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · Lesson

Subdomain and Path-Based Tenant Resolution

Resolve tenants from subdomains or path prefixes using middleware rewrites and headers.

Subdomain and Path-Based Tenant Resolution is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

What Is Tenant Resolution?

In a multi-tenant SaaS application, every incoming request must be mapped to a specific tenant before any business logic runs. This process is called tenant resolution.

There are two dominant strategies:

  • Subdomain-based: acme.app.com, globex.app.com — each tenant owns a subdomain.
  • Path-based: app.com/acme/dashboard, app.com/globex/dashboard — the tenant slug appears as the first path segment.

Next.js 15 middleware runs on the Edge Runtime before any page or API route is processed, making it the ideal place to extract, validate, and forward tenant context to the rest of the application.

Next.js Middleware Basics

Middleware in Next.js 15 lives in middleware.ts at the project root. It exports a default middleware function and an optional config object with a matcher array.

Key points:

  • Runs on every matched request before the route handler.
  • Can read request headers, cookies, and the URL.
  • Can rewrite the request URL (invisible to the browser) or redirect it.
  • Can attach custom request headers that page components and API routes can later read.

The middleware function receives a NextRequest and must return a NextResponse.

// middleware.ts — minimal skeleton
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest): NextResponse {
  // Inspect request.nextUrl, request.headers, request.cookies …
  return NextResponse.next();
}

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

Extracting the Tenant from a Subdomain

For subdomain-based resolution, parse request.nextUrl.hostname. Given a host like acme.app.com, split on . and take the first segment.

Important edge cases to handle:

  • localhost — no subdomain exists during local development; fall back to a default tenant or a query parameter.
  • www — the marketing site prefix should not be treated as a tenant slug.
  • IP addresses used in CI/Docker environments.

Once extracted, store the slug in a response header so every downstream component can access it without re-parsing the host.

// lib/tenant/resolveSubdomain.ts
export function resolveSubdomain(
  hostname: string,
  rootDomain: string   // e.g. 'app.com'
): string | null {
  // Strip port if present (e.g. localhost:3000)
  const host = hostname.split(':')[0];

  if (host === 'localhost' || host === '127.0.0.1') {
    return null; // handled separately in dev
  }

  if (!host.endsWith(`.${rootDomain}`)) {
    return null; // top-level domain — no tenant
  }

  const subdomain = host.slice(0, host.length - rootDomain.length - 1);
  if (subdomain === 'www' || subdomain === '') return null;

  return subdomain; // e.g. 'acme'
}

Extracting the Tenant from a Path Prefix

For path-based resolution, inspect request.nextUrl.pathname. A URL like /acme/dashboard yields a path where the first segment is the tenant slug.

The middleware must:

  1. Split the pathname on /.
  2. Validate the first non-empty segment against a known set of tenant slugs (or a database lookup at the edge).
  3. Rewrite the URL to a tenant-agnostic route, stripping the prefix so the page component does not need to know about it.

The rewrite keeps the browser URL intact while routing internally to /dashboard (without the slug prefix).

// lib/tenant/resolvePath.ts
export function resolvePathTenant(
  pathname: string
): { tenant: string | null; strippedPath: string } {
  const segments = pathname.split('/').filter(Boolean);

  if (segments.length === 0) {
    return { tenant: null, strippedPath: '/' };
  }

  // First segment is treated as the tenant slug.
  // Validation against a real slug set happens in middleware.
  const [tenantSlug, ...rest] = segments;
  const strippedPath = '/' + rest.join('/');

  return { tenant: tenantSlug, strippedPath: strippedPath || '/' };
}

Rewriting URLs in Middleware

A rewrite changes the internal destination of the request without altering what the user sees in their browser address bar. This is the mechanism that lets path-based multi-tenancy feel transparent.

For example, when a user visits app.com/acme/dashboard, middleware rewrites the request to app.com/dashboard internally, while the tenant slug acme is passed forward via a custom request header.

Use NextResponse.rewrite(url) and attach headers to the rewritten response. Note that in Next.js 15 you must clone request.headers and set tenant headers on the new request inside the rewrite, not on the response headers.

// middleware.ts — path-based rewrite example
import { NextRequest, NextResponse } from 'next/server';
import { resolvePathTenant } from './lib/tenant/resolvePath';

const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? 'app.com';

export function middleware(request: NextRequest) {
  const { tenant, strippedPath } = resolvePathTenant(
    request.nextUrl.pathname
  );

  if (!tenant) return NextResponse.next();

  const url = request.nextUrl.clone();
  url.pathname = strippedPath;

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-tenant-slug', tenant);

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

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

Unified Middleware: Subdomain + Path Fallback

A production-grade middleware combines both strategies: prefer subdomain resolution; fall back to path-based resolution when no subdomain is found (useful for white-label partners who share a domain or for local development).

The lookup order is:

  1. Check request.nextUrl.hostname for a tenant subdomain.
  2. If not found, check the first pathname segment.
  3. If neither yields a tenant, let the request through unchanged (landing page, auth routes, etc.).

After resolution, the tenant slug is forwarded in the x-tenant-slug request header so every Server Component, Server Action, and Route Handler can read it without repeating the parsing logic.

// middleware.ts — unified resolver
import { NextRequest, NextResponse } from 'next/server';
import { resolveSubdomain } from './lib/tenant/resolveSubdomain';
import { resolvePathTenant } from './lib/tenant/resolvePath';

const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? 'app.com';

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

  // 1. Try subdomain first
  let tenantSlug = resolveSubdomain(hostname, ROOT_DOMAIN);
  let rewritePath: string | null = null;

  // 2. Fall back to path prefix
  if (!tenantSlug) {
    const result = resolvePathTenant(pathname);
    tenantSlug = result.tenant;
    rewritePath = result.strippedPath;
  }

  if (!tenantSlug) return NextResponse.next(); // public route

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-tenant-slug', tenantSlug);

  if (rewritePath) {
    const url = request.nextUrl.clone();
    url.pathname = rewritePath;
    return NextResponse.rewrite(url, { request: { headers: requestHeaders } });
  }

  // Subdomain path — no URL rewrite needed, just forward the header
  return NextResponse.next({ request: { headers: requestHeaders } });
}

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

Reading Tenant Context in Server Components

Once middleware sets the x-tenant-slug header, every Server Component can read it by calling headers() from next/headers. This is a Next.js 15 async API — always await it.

Best practice: wrap header access in a small utility function (getTenantSlug) so every component imports the same helper instead of duplicating the header key string.

The tenant slug is then used to query the database for the full tenant record (id, plan, theme, feature flags, etc.) — typically cached with unstable_cache or React's cache() to avoid redundant DB hits per request.

// lib/tenant/server.ts
import { headers } from 'next/headers';
import { cache } from 'react';
import { db } from '@/lib/db'; // your DB client

export async function getTenantSlug(): Promise<string> {
  const headerStore = await headers();
  const slug = headerStore.get('x-tenant-slug');
  if (!slug) throw new Error('No tenant slug in request headers');
  return slug;
}

// Cached per request — React deduplicates across the component tree
export const getTenant = cache(async () => {
  const slug = await getTenantSlug();
  const tenant = await db.tenant.findUnique({ where: { slug } });
  if (!tenant) throw new Error(`Unknown tenant: ${slug}`);
  return tenant;
});

// Usage inside any Server Component:
// const tenant = await getTenant();
// <ThemeProvider primary={tenant.brandColor}>...</ThemeProvider>

Validating Tenants at the Edge

Resolving a tenant slug from the URL is cheap, but you may want to validate that the slug actually exists before letting the request proceed. Doing this in middleware avoids a 404 deep inside a Server Component.

Edge validation options (middleware cannot use Node.js APIs):

  • KV store / Upstash Redis: fetch a pre-populated set of valid slugs with a single HTTP call.
  • Edge-compatible ORM: Drizzle or Prisma Accelerate can run on the Edge Runtime.
  • Static allowlist: suitable only for a small, infrequently-changing tenant list baked into the deployment.

If validation fails, redirect the user to the 404 or a custom tenant-not-found page rather than returning a blank response.

// middleware.ts — Edge KV validation with Upstash Redis
import { NextRequest, NextResponse } from 'next/server';
import { Redis } from '@upstash/redis';
import { resolveSubdomain } from './lib/tenant/resolveSubdomain';

const redis = Redis.fromEnv();
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? 'app.com';

export async function middleware(request: NextRequest) {
  const { hostname } = request.nextUrl;
  const tenantSlug = resolveSubdomain(hostname, ROOT_DOMAIN);

  if (!tenantSlug) return NextResponse.next();

  // 'tenants' is a Redis Set populated whenever a tenant is created/deleted
  const exists = await redis.sismember('tenants', tenantSlug);
  if (!exists) {
    return NextResponse.redirect(
      new URL('/tenant-not-found', request.url)
    );
  }

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-tenant-slug', tenantSlug);
  return NextResponse.next({ request: { headers: requestHeaders } });
}

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

Tenant Context in Server Actions

Server Actions run in the same request context as Server Components. The middleware-injected x-tenant-slug header is available via headers() here too, making it trivial to scope all data mutations to the correct tenant.

A critical security rule: never trust tenant slugs passed from the client (e.g., a hidden form field). Always derive the tenant from the server-side header that only middleware can set. This prevents tenant impersonation attacks where a malicious user crafts a request with a forged slug in the body.

'use server';
// app/[...]/actions.ts
import { headers } from 'next/headers';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; // your auth helper

export async function createProject(formData: FormData) {
  // Derive tenant from middleware header — never from form data
  const headerStore = await headers();
  const tenantSlug = headerStore.get('x-tenant-slug');
  if (!tenantSlug) throw new Error('Missing tenant context');

  const session = await auth();
  if (!session) throw new Error('Unauthenticated');

  const name = formData.get('name') as string;

  // Scope the insert to the resolved tenant
  const tenant = await db.tenant.findUniqueOrThrow({
    where: { slug: tenantSlug },
    select: { id: true },
  });

  return db.project.create({
    data: { name, tenantId: tenant.id, ownerId: session.user.id },
  });
}

Local Development Without Real Subdomains

Subdomains don't work on localhost out of the box. Two practical solutions for local development:

  • /etc/hosts mapping: Add entries like 127.0.0.1 acme.localhost. Most browsers support *.localhost natively without a hosts entry in modern versions.
  • Query-parameter override: In development, middleware reads a ?tenant=acme query param as a fallback when no subdomain is present. This override is disabled in production via an environment variable check.

The query-parameter approach is fastest for iteration — no system configuration required — and easy to share demo links with teammates.

// middleware.ts — dev query-param tenant override
import { NextRequest, NextResponse } from 'next/server';
import { resolveSubdomain } from './lib/tenant/resolveSubdomain';

const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? 'app.com';
const IS_DEV = process.env.NODE_ENV === 'development';

export function middleware(request: NextRequest) {
  const { hostname, searchParams } = request.nextUrl;

  let tenantSlug = resolveSubdomain(hostname, ROOT_DOMAIN);

  // Dev-only fallback: ?tenant=acme
  if (!tenantSlug && IS_DEV) {
    tenantSlug = searchParams.get('tenant');
  }

  if (!tenantSlug) return NextResponse.next();

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-tenant-slug', tenantSlug);

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

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

Generating Tenant-Scoped URLs

Links within the app must be generated correctly depending on which resolution strategy is in use. Hardcoding one style breaks the other. A small utility function centralises this logic.

It reads the NEXT_PUBLIC_TENANT_STRATEGY environment variable ('subdomain' or 'path') and constructs the URL accordingly. This is called in Server Components when building <Link href> values or in client utilities that generate share links.

// lib/tenant/url.ts
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? 'app.com';
const STRATEGY = (process.env.NEXT_PUBLIC_TENANT_STRATEGY ?? 'subdomain') as
  | 'subdomain'
  | 'path';

/**
 * Build an absolute URL for a given tenant and path.
 * @param slug  - tenant slug, e.g. 'acme'
 * @param path  - app path, e.g. '/dashboard'
 */
export function tenantUrl(slug: string, path: string = '/'): string {
  const normalizedPath = path.startsWith('/') ? path : `/${path}`;

  if (STRATEGY === 'subdomain') {
    return `https://${slug}.${ROOT_DOMAIN}${normalizedPath}`;
  }

  // path-based: prepend slug as first segment
  return `https://${ROOT_DOMAIN}/${slug}${normalizedPath}`;
}

// Example usage:
// tenantUrl('acme', '/dashboard') => 'https://acme.app.com/dashboard'
// tenantUrl('acme', '/dashboard') => 'https://app.com/acme/dashboard'

Knowledge Check: Middleware Header Propagation

Consider the following middleware snippet that resolves a tenant slug and needs to pass it to downstream Server Components without altering the browser URL:

const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-tenant-slug', tenantSlug);
return NextResponse.next({ request: { headers: requestHeaders } });

Which statement best explains why the header is set on the request object inside NextResponse.next() rather than on the response?

Recap: Subdomain and Path-Based Tenant Resolution

This lesson covered the two core strategies for resolving tenant context in a Next.js 15 multi-tenant application:

  • Subdomain-based: parse request.nextUrl.hostname in middleware to extract the slug (e.g., acme.app.comacme). No URL rewrite is needed; just forward the slug in a request header.
  • Path-based: extract the first pathname segment (e.g., /acme/dashboardacme), rewrite the URL to strip the prefix, and again forward the slug in a request header.

Key principles to remember:

  • Use NextResponse.next({ request: { headers } }) to forward custom headers to Server Components and Server Actions.
  • Always read tenant identity from the server-side header — never trust client-supplied form fields or query params in production.
  • Validate tenant existence at the Edge (KV store / Edge ORM) to fail fast before hitting any route handler.
  • Centralise URL generation in a tenantUrl() utility driven by an environment variable so switching strategies requires no code changes.
  • For local development, *.localhost subdomains or a ?tenant= query-param fallback bypass the need for DNS or /etc/hosts changes.

Frequently asked questions

Is the “Subdomain and Path-Based Tenant Resolution” lesson free?

Yes — the full text of “Subdomain and Path-Based Tenant Resolution” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.

What will I learn in “Subdomain and Path-Based Tenant Resolution”?

Resolve tenants from subdomains or path prefixes using middleware rewrites and headers. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?

No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Subdomain and Path-Based Tenant Resolution” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?

Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Subdomain and Path-Based Tenant Resolution
  2. Row-Level Tenant Data Isolation Patterns
  3. Per-Tenant Theming and Feature Flags
  4. Usage Metering and Subscription Enforcement
← Back to Next.js 15 Fullstack (App Router + Server Actions)