서브도메인 및 경로 기반 테넌트 확인
미들웨어 재작성과 헤더를 사용하여 서브도메인 또는 경로 접두사에서 테넌트를 확인하는 방법을 배웁니다.
서브도메인 및 경로 기반 테넌트 확인은(는) CoddyKit의 무료 Next.js 15 Fullstack (App Router + Server Actions) 강의입니다. 이것은 4개 중 1번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Next.js 15 Fullstack (App Router + Server Actions) 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.
이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.
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:
- Split the pathname on
/. - Validate the first non-empty segment against a known set of tenant slugs (or a database lookup at the edge).
- 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:
- Check
request.nextUrl.hostnamefor a tenant subdomain. - If not found, check the first pathname segment.
- 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/hostsmapping: Add entries like127.0.0.1 acme.localhost. Most browsers support*.localhostnatively without a hosts entry in modern versions.- Query-parameter override: In development, middleware reads a
?tenant=acmequery 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.hostnamein middleware to extract the slug (e.g.,acme.app.com→acme). No URL rewrite is needed; just forward the slug in a request header. - Path-based: extract the first pathname segment (e.g.,
/acme/dashboard→acme), 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,
*.localhostsubdomains or a?tenant=query-param fallback bypass the need for DNS or/etc/hostschanges.
자주 묻는 질문
“서브도메인 및 경로 기반 테넌트 확인” 강의는 무료인가요?
네 — “서브도메인 및 경로 기반 테넌트 확인” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Next.js 15 Fullstack (App Router + Server Actions) 강의 전체를 잠금 해제할 수 있습니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.
“서브도메인 및 경로 기반 테넌트 확인”에서 뭘 배우나요?
미들웨어 재작성과 헤더를 사용하여 서브도메인 또는 경로 접두사에서 테넌트를 확인하는 방법을 배웁니다. 브라우저에서 직접 실행하는 실습 코드로 Next.js 15 Fullstack (App Router + Server Actions)을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Next.js 15 Fullstack (App Router + Server Actions)을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Next.js 15 Fullstack (App Router + Server Actions)은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 1번째 강의입니다.
“서브도메인 및 경로 기반 테넌트 확인” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 Next.js 15 Fullstack (App Router + Server Actions) 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 Next.js 15 Fullstack (App Router + Server Actions) 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- 서브도메인 및 경로 기반 테넌트 확인
- 행 수준 테넌트 데이터 격리 패턴
- 테넌트별 테마와 기능 플래그
- 사용량 측정과 구독 적용