Skip to content
Docs

Proxy and Routing

Tenant headers must come from the proxy, never from the client. Any caller can attach an x-tenant-id header to a request, and if the proxy forwards that value untouched, your app trusts it and serves data for whichever tenant the caller picked. The examples below delete or overwrite each tenant header they set before forwarding the request, including on paths that skip tenant resolution, so a client-supplied x-tenant-id never reaches a route that runs behind the proxy. If your app reads other x-tenant-* headers, strip those in the proxy too. Requests to paths that a matcher excludes, such as /api in the first example, skip the proxy entirely, so don't trust tenant headers in those routes.

Extract tenant identity from subdomains like tenant1.yourapp.com:

// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
 
export async function proxy(request: NextRequest) {
  const hostname = request.headers.get('host') || '';
 
  // Strip inbound tenant headers; only the proxy sets them
  const requestHeaders = new Headers(request.headers);
  requestHeaders.delete('x-tenant-id');
  requestHeaders.delete('x-tenant-subdomain');
 
  // This is your platform's own domain, such as yourapp.com. We default to
  // localhost:3000 so that the sample also works in local development.
  const rootDomain = process.env.ROOT_DOMAIN ?? 'localhost:3000';
 
  // Requests to the root domain go to your own app. Other hosts, such as
  // custom domains, are handled by the custom domain sample below.
  if (hostname === rootDomain || !hostname.endsWith(`.${rootDomain}`)) {
    return NextResponse.next({ request: { headers: requestHeaders } });
  }
 
  // Now we take the subdomain off the front of the host to get the tenant.
  // For example, tenant1.yourapp.com gives us tenant1.
  const subdomain = hostname.slice(0, -(rootDomain.length + 1));
 
  if (subdomain === 'www' || subdomain === 'app' || subdomain === 'admin') {
    return NextResponse.next({ request: { headers: requestHeaders } });
  }
 
  // Validate tenant exists (you might cache this for performance)
  const tenant = await validateTenant(subdomain);
 
  if (!tenant) {
    // Note that we use rewrite instead of redirect here, since a redirect to
    // /not-found would send the browser back through the proxy, which would
    // redirect it again in an endless loop.
    return NextResponse.rewrite(new URL('/not-found', request.url), {
      request: { headers: requestHeaders },
    });
  }
 
  // Forward tenant context to your app on the request headers
  requestHeaders.set('x-tenant-id', tenant.id);
  requestHeaders.set('x-tenant-subdomain', subdomain);
 
  return NextResponse.next({ request: { headers: requestHeaders } });
}
 
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

This example:

  • Extracts tenant1 from tenant1.yourapp.com
  • Validates the tenant exists in your database
  • Strips inbound tenant headers so clients can't forge tenant context
  • Adds tenant context to request headers for your app to use
  • Returns a 404 page for unknown subdomains. We use a rewrite here, since a redirect would send the request through the proxy again in a loop. The quickstart takes another approach and returns a plain 404 response from the proxy
  • Lets the root domain (yourapp.com, or localhost:3000 locally) through to your own app

Handle custom domains like tenant.com mapping to tenants:

// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
import { get } from '@vercel/global-config';
 
// Global Config keys can contain only letters, digits, _ and -, so we
// replace the . and : in a host name before we use it in a key.
const key = (value: string) => value.replaceAll('.', '_').replaceAll(':', '_');
 
export async function proxy(request: NextRequest) {
  const hostname = request.headers.get('host') || '';
  const rootDomain = process.env.ROOT_DOMAIN ?? 'localhost:3000';
  // Strip inbound tenant headers; only the proxy sets them
  const requestHeaders = new Headers(request.headers);
  requestHeaders.delete('x-tenant-id');
  requestHeaders.delete('x-tenant-type');
 
  // Check if this is a custom domain
  const customDomainTenant = await get<{ id: string }>(
    `domain_${key(hostname)}`,
  );
  if (customDomainTenant) {
    requestHeaders.set('x-tenant-id', customDomainTenant.id);
    requestHeaders.set('x-tenant-type', 'custom-domain');
    return NextResponse.next({ request: { headers: requestHeaders } });
  }
 
  // If the host isn't a custom domain, we check whether it's a subdomain
  // of your root domain, such as tenant1.yourapp.com.
  const subdomain = hostname.endsWith(`.${rootDomain}`)
    ? hostname.slice(0, -(rootDomain.length + 1))
    : null;
  const subdomainTenant = subdomain
    ? await get<{ id: string }>(`subdomain_${key(subdomain)}`)
    : undefined;
  if (subdomainTenant) {
    requestHeaders.set('x-tenant-id', subdomainTenant.id);
    requestHeaders.set('x-tenant-type', 'subdomain');
    return NextResponse.next({ request: { headers: requestHeaders } });
  }
 
  // Requests to the root domain go to your own app.
  if (hostname === rootDomain) {
    return NextResponse.next({ request: { headers: requestHeaders } });
  }
 
  // No tenant matched. As in the sample above, we rewrite instead of
  // redirect, so that the request doesn't loop through the proxy.
  return NextResponse.rewrite(new URL('/not-found', request.url), {
    request: { headers: requestHeaders },
  });
}
 
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

This example:

  • First checks if the hostname is a registered custom domain
  • Falls back to the subdomain of your root domain, and lets the root domain itself through
  • Uses Global Config for fast tenant lookups. Global Config keys allow only letters, digits, _, and -, so the sample replaces . and : in the hostname
  • Sets tenant type so your app knows how the tenant was resolved

Extract tenant from URL paths like /tenant1/dashboard:

// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
 
export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // Strip inbound tenant headers; only the proxy sets them
  const requestHeaders = new Headers(request.headers);
  requestHeaders.delete('x-tenant-id');
  requestHeaders.delete('x-tenant-slug');
 
  // Extract tenant from first path segment: /tenant_name_1/dashboard
  const pathSegments = pathname.split('/');
  const tenantSlug = pathSegments[1];
 
  if (!tenantSlug || tenantSlug.startsWith('_')) {
    return NextResponse.next({ request: { headers: requestHeaders } });
  }
 
  // Validate tenant exists
  const tenant = await validateTenant(tenantSlug);
 
  if (!tenant) {
    return NextResponse.rewrite(new URL('/not-found', request.url), {
      request: { headers: requestHeaders },
    });
  }
 
  // Rewrite URL to remove tenant from path. We clone nextUrl instead of
  // building a new URL, so that the query string stays on the request.
  const rewriteUrl = request.nextUrl.clone();
  rewriteUrl.pathname = `/${pathSegments.slice(2).join('/')}`;
 
  requestHeaders.set('x-tenant-id', tenant.id);
  requestHeaders.set('x-tenant-slug', tenantSlug);
 
  return NextResponse.rewrite(rewriteUrl, {
    request: { headers: requestHeaders },
  });
}
 
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

This example:

  • Extracts tenant1 from /tenant1/dashboard
  • Rewrites the URL to /dashboard (removes tenant from path)
  • Your app receives /dashboard with tenant context in headers
  • Skips processing for Next.js internal routes (starting with _)

Route different URLs to tenant-specific pages:

// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
 
export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const hostname = request.headers.get('host') || '';
  const tenantId = await resolveTenantId(hostname);
 
  // Strip the inbound tenant header; only the proxy sets it
  const requestHeaders = new Headers(request.headers);
  requestHeaders.delete('x-tenant-id');
 
  if (!tenantId) {
    return NextResponse.rewrite(new URL('/not-found', request.url), {
      request: { headers: requestHeaders },
    });
  }
 
  requestHeaders.set('x-tenant-id', tenantId);
 
  // Route to tenant-specific pages
  if (pathname === '/') {
    // Rewrite homepage to tenant-specific version
    const url = request.nextUrl.clone();
    url.pathname = `/tenant/${tenantId}`;
    return NextResponse.rewrite(url, {
      request: { headers: requestHeaders },
    });
  }
 
  if (pathname.startsWith('/blog')) {
    // Route blog requests to tenant-specific blog
    const url = request.nextUrl.clone();
    url.pathname = `/tenant/${tenantId}/blog${pathname.replace('/blog', '')}`;
    return NextResponse.rewrite(url, {
      request: { headers: requestHeaders },
    });
  }
 
  // Forward tenant context to all other requests
  return NextResponse.next({ request: { headers: requestHeaders } });
}

This example:

  • Routes tenant1.app.com/ to /tenant/tenant1 page
  • Routes tenant1.app.com/blog/post-1 to /tenant/tenant1/blog/post-1 page
  • Other routes get tenant context but keep the same URL
  • Your page components can access tenant ID from headers
Last updated August 10, 2026

Was this helpful?

supported.