В современной разработке программного обеспечения как услуги (SaaS) парадигма мультитенантности (Multi-Tenancy) является стандартом де-факто для B2B-платформ. Вместо развертывания отдельного инстанса приложения и базы данных для каждой организации, единый кластер Next.js 16 обслуживает миллионы пользователей, маршрутизируя входящий трафик на основе заголовка Host.
Архитектура распределения доменов в Next.js 16 разделяется на три уровня:
app.platform.io, platform.io): публичные лендинги, аутентификация, биллинг и глобальная консоль управления.tenant-a.platform.io, tenant-b.platform.io): изолированные пространства арендаторов, выдаваемые автоматически при регистрации.portal.customer.com, shop.enterprise.org): брендированные домены с собственными SSL/TLS-сертификатами, направленные CNAME-записью на инфраструктуру платформы.Ключевой вызов проектирования — обеспечение нулевых утечек данных (Zero Data Leakage), детерминированного кэширования на уровне CDN и автоматизированного выпуска сертификатов без перезапуска сервисов. Next.js 16 решает эту задачу комбинацией прозрачных перезаписей путей (URL Rewrites) на пограничном слое (proxy.ts), динамического дерева маршрутов app/[domain]/... и криптографически изолированного кэширования через тегирование tenant:${id}.
Ментальная модель мультитенантной маршрутизации строится на концепции виртуализации файловой структуры. Пользователь обращается по каноническому адресу https://portal.acme-corp.com/dashboard/analytics. Для браузера URL остается неизменным, однако на пограничном слое обратный прокси трансформирует входящий сетевой кортеж { Host, Pathname } во внутренний путь файловой системы Next.js:
Входящий HTTP-запрос:
Host: portal.acme-corp.com
Path: /dashboard/analytics
│
▼
┌────────────────────────────────────────────────────────┐
│ Edge Proxy Layer (proxy.ts / пограничный роутер) │
│ 1. Извлечение Host: portal.acme-corp.com │
│ 2. Резолв в Tenant ID: tenant_984f2 │
│ 3. Внутренний Rewrite: /[domain]/dashboard/analytics │
│ 4. Инъекция заголовка: x-tenant-id: tenant_984f2 │
└────────────────────────────────────────────────────────┘
│
▼
Внутренний роутер Next.js 16 (App Router):
app/[domain]/dashboard/analytics/page.tsx
│
▼
Data Access Layer (DAL) & Cache:
- Cache Tag: `tenant:tenant_984f2`
- Database: WHERE tenant_id = 'tenant_984f2' (RLS)Браузер клиента не видит сегмента /[domain]. Сегмент [domain] существует исключительно в адресном пространстве серверного дерева компонентов Next.js, выступая корневым изолятором контекста.
Схема маршрутизации доменов
Пользователь А Пользователь Б Пользователь В
(acme.platform.io) (custom-corp.com) (platform.io/login)
│ │ │
└─────────────────────────┬─────────┴──────────────────────────────────────┘
▼
┌───────────────────────────┐
│ Пограничный шлюз │
│ (Edge Proxy / proxy.ts) │
└─────────────┬─────────────┘
│
┌────────────────────────┼────────────────────────┐
│ Rewrite │ Rewrite │ Pass-through
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ app/[domain]/... │ │ app/[domain]/... │ │ app/(main)/... │
│ domain = "acme" │ │ domain = "custom"│ │ Корневой сайт │
└──────────────────┘ └──────────────────┘ └──────────────────┘На пограничном прокси proxy.ts извлекается хост запроса, отсекаются порты разработки и нормализуются символы нижнего регистра.
Проверка поддомена в L1 In-Memory кэше пограничного узла (TTL 60 с). При промахе — асинхронный запрос к распределенному KV/Redis для кастомных доменов.
Формирование внутреннего пути Next.js: NextResponse.rewrite(new URL(`/${resolvedDomain}${pathname}`, req.url)) с установкой заголовка x-tenant-id.
Компонент page.tsx считывает domain из параметров пути и верифицирует tenant_id через Data Access Layer, исключая межарендаторные утечки.
Кэшируемые результаты снабжаются изолированными тегами вида tenant:${tenantId} и domain:${domain} для гранулярной инвалидации.
Почему при реализации мультитенантности в Next.js 16 используется внутренний Rewrite на уровне прокси вместо HTTP Redirect (301/302)?
Крупные платформы управления проектами и CRM (аналоги Linear, Notion, Monday) предоставляют каждой корпорации уникальный URL вида acme.workspaces.io. Платформа изолирует рабочие процессы сотен тысяч компаний в едином коде, гарантируя изоляцию прав доступа на уровне Row-Level Security (RLS) в PostgreSQL.
SaaS-конструкторы интернет-магазинов позволяют мерчантам подключать собственные домены первого и второго уровней (shop.nike-partner.de). Next.js 16 рендерит единую архитектуру чекаута и каталога, динамически применяя стили, валюты, налоги и каталоги нужного арендатора.
Сервисы публикации баз знаний (аналоги GitBook, ReadMe) дают клиентам возможность привязывать технические поддомены docs.client.com. Пограничный прокси мгновенно сопоставляет домен с базой статей и применяет изолированное кэширование, выдерживая трафик в десятки тысяч RPS.
Банковские сервисы для партнеров требуют развертывания порталов самообслуживания на доверенных доменах партнерских финансовых институтов со строгой изоляцией криптографических ключей сессий и аудиторским логированием каждого запроса по tenant_id.
proxy.ts#В Next.js 16 для высокопроизводительной пограничной маршрутизации и трансформации запросов используется выделенный файл конфигурации пограничного прокси proxy.ts (или канонический middleware.ts с рантаймом edge). Он исключает статические ассеты, внутренние системные эндпоинты _next и фавиконки, передавая на маршрутизацию только прикладной трафик.
import { NextRequest, NextResponse } from 'next/server';
// Регулярное выражение для исключения статических ресурсов и внутренних API
const PUBLIC_FILE = /\.(.*)$/;
// Базовый домен платформы из переменных окружения
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'platform.io';
export async function proxy(req: NextRequest) {
const url = req.nextUrl;
const hostname = req.headers.get('host') || ROOT_DOMAIN;
// Очистка имени хоста от порта (актуально для локальной разработки localhost:3000)
const currentHost = hostname.replace(/:\d+$/, '').toLowerCase();
// Игнорируем внутренние пути Next.js, API вебхуков и статические файлы
if (
url.pathname.startsWith('/_next') ||
url.pathname.startsWith('/api/webhooks') ||
PUBLIC_FILE.test(url.pathname)
) {
return NextResponse.next();
}
// 1. Сценарий: Локальная разработка или корневой домен платформы
if (currentHost === ROOT_DOMAIN || currentHost === 'localhost') {
// URL вида platform.io/pricing -> рендерится из app/(marketing)/...
return NextResponse.rewrite(new URL(`/marketing${url.pathname}`, req.url));
}
// 2. Сценарий: Системный поддомен (например, acme.platform.io)
let detectedTenantDomain: string;
if (currentHost.endsWith(`.${ROOT_DOMAIN}`)) {
const subdomain = currentHost.replace(`.${ROOT_DOMAIN}`, '');
detectedTenantDomain = subdomain;
} else {
// 3. Сценарий: Кастомный домен клиента (например, portal.customer.com)
detectedTenantDomain = currentHost;
}
// Перезаписываем внутренний путь на app/[domain]/...
// Браузер видит исходный URL, Next.js направляет запрос в сегмент [domain]
const rewriteUrl = new URL(`/${detectedTenantDomain}${url.pathname}${url.search}`, req.url);
// Создаем ответ с перезаписью и пробрасываем служебный заголовок x-tenant-host
const response = NextResponse.rewrite(rewriteUrl);
response.headers.set('x-tenant-host', detectedTenantDomain);
return response;
}
export const config = {
matcher: [
/*
* Применяем прокси ко всем маршрутам, кроме служебных путей
*/
'/((?!api/health|_next/static|_next/image|favicon.ico).*)',
],
};Извлечение заголовка Host из заголовков HTTP/2 или HTTP/3, нормализация регистра и очистка порта.
Пограничный слой выполняется на Edge Runtime со средним временем отклика менее 2 мс.
Сопоставление хоста с зарегистрированным тенантом: проверка нативного поддомена либо поиск кастомного домена в защищенном реестре.
Для кастомных доменов используется распределенный LRU-кэш для снижения нагрузки на основную базу.
Формирование виртуального пути в формате /[domain]/[...path] с передачей контекста через контекстные заголовки запроса.
Клиент видит оригинальный адрес без перенаправлений браузера.
Исполнение React Server Components с выборкой данных, строго ограниченной tenant_id, и тегированием кэша tenant:${tenantId}.
Исключает возможность попадания чужих данных в кэш благодаря составным ключам.
Next.js 16 использует динамический сегмент верхнего уровня [domain]:
app/
├── (marketing)/ # Публичный сайт платформы (platform.io)
│ ├── page.tsx # Главная страница платформы
│ └── pricing/
│ └── page.tsx
├── [domain]/ # Виртуализированное пространство тенантов
│ ├── layout.tsx # Мультитенантный макет (темы, навигация арендатора)
│ ├── page.tsx # Домашняя страница тенанта
│ ├── settings/
│ │ └── page.tsx
│ └── api/
│ └── data/
│ └── route.ts
└── api/
└── domains/
└── verify/ # Проверка владения доменом (DNS verification)
└── route.tsimport { notFound } from 'next/navigation';
import { getTenantByDomain } from '@/lib/dal/tenant';
import { TenantProvider } from '@/components/tenant-provider';
interface TenantLayoutProps {
children: React.ReactNode;
params: Promise<{ domain: string }>;
}
export async function generateMetadata(props: TenantLayoutProps) {
const { domain } = await props.params;
const tenant = await getTenantByDomain(domain);
if (!tenant) {
return { title: 'Tenant Not Found' };
}
return {
title: `${tenant.name} | Портал управления`,
description: tenant.description,
icons: [{ rel: 'icon', url: tenant.faviconUrl || '/default-favicon.ico' }],
};
}
export default async function TenantLayout(props: TenantLayoutProps) {
const { domain } = await props.params;
const tenant = await getTenantByDomain(domain);
// Если домен не привязан ни к одному арендатору, возвращаем 404
if (!tenant) {
notFound();
}
return (
<div
className="min-h-screen flex flex-col"
style={{
// Динамическая инъекция корпоративных цветов арендатора
['--primary-color' as string]: tenant.theme.primaryColor,
['--font-family' as string]: tenant.theme.fontFamily,
}}
>
<header className="border-b border-border px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<img
src={tenant.logoUrl}
alt={tenant.name}
className="h-8 w-auto object-contain"
/>
<span className="font-semibold text-lg text-text-primary">{tenant.name}</span>
</div>
<div className="text-xs text-text-muted font-mono">
Tenant ID: {tenant.id}
</div>
</header>
<main className="flex-1 p-6">
<TenantProvider tenantId={tenant.id} domain={domain}>
{props.children}
</TenantProvider>
</main>
</div>
);
}use cache и cacheTag#В Next.js 16 директива "use cache" комбинируется с функцией cacheTag для предотвращения перекрестной утечки данных между организациями (Cross-Tenant Cache Contamination). Каждый кэшируемый фрагмент ОБЯЗАН быть помечен уникальным составным тегом, включающим tenant_id.
import { unstable_cacheTag as cacheTag, unstable_cacheLife as cacheLife } from 'next/cache';
import { db } from '@/lib/db';
export interface AnalyticsSummary {
activeUsers: number;
monthlyRevenue: number;
churnRate: number;
}
/**
* Извлечение аналитических метрик с жесткой изоляцией кэша
*/
export async function getTenantAnalytics(tenantId: string): Promise<AnalyticsSummary> {
'use cache';
// Устанавливаем профиль жизненного цикла кэша
cacheLife('hours');
// КРИТИЧЕСКИ ВАЖНО: Тегируем кэш уникальным идентификатором арендатора.
// Это исключает вероятность того, что Tenant B получит в ответе кэш Tenant A.
cacheTag(`tenant:${tenantId}`, `tenant:${tenantId}:analytics`);
// Запрос в базу данных с обязательным предикатом по tenant_id
const metrics = await db.analytics.aggregate({
where: {
tenantId: tenantId,
timestamp: {
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // последние 30 дней
},
},
_sum: { revenue: true },
_count: { userId: true },
});
return {
activeUsers: metrics._count.userId,
monthlyRevenue: metrics._sum.revenue || 0,
churnRate: 1.2,
};
}При изменении настроек или обновлении данных арендатора инвалидация запускается точечно без сброса кэша других клиентов платформы:
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { verifySystemAdminAuth } from '@/lib/auth/admin';
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ tenantId: string }> }
) {
const { tenantId } = await params;
// Авторизация сервисного вызова
const isAuthorized = await verifySystemAdminAuth(req);
if (!isAuthorized) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Инвалидируем кэш строго в рамках одного тенанта
revalidateTag(`tenant:${tenantId}`, 'hard');
return NextResponse.json({
status: 'success',
revalidatedTenant: tenantId,
timestamp: Date.now(),
});
}Для кастомных доменов (company.com) невозможно положить сертификаты в статический бандл. Выпуск и автоматическое продление осуществляются через следующие подходы:
Инженер заметил, что при открытии /reports на домене acme.platform.io иногда отображается отчет компании globex.platform.io. В коде используется директива 'use cache'. В чем первопричина инцидента?
Хранение текущего контекста арендатора в глобальной переменной уровня модуля (let currentTenantId: string) в серверном коде фатально опасно. Поскольку Node.js обрабатывает множество параллельных асинхронных запросов в рамках единого потока событий, глобальная переменная неизбежно перезапишется параллельным запросом другого пользователя.
Как избежать: Всегда используйте AsyncLocalStorage из node:async_hooks либо прокидывайте tenant_id явно через аргументы функций Data Access Layer.
Попытка определить текущий тенант на клиенте через чтение window.location.hostname в useEffect вызывает мерцание интерфейса (Cumulative Layout Shift) и делает невозможным предварительный рендеринг страницы на сервере (SSR/SSG).
Как избежать: Разрешайте домен исключительно на сервере в proxy.ts и app/[domain]/layout.tsx, отправляя в браузер готовое DOM-дерево с правильными темами и метаданными.
Если клиент удалил CNAME-запись со своего домена либо передал управление доменом третьей стороне, а система продолжает считать домен активным, возникает уязвимость Subdomain Takeover. Как избежать: Реализуйте фоновый сервис верификации (Health Check Cron), который каждые 6 часов опрашивает DNS кастомных доменов и деактивирует маршрутизацию при исчезновении CNAME-записи платформы.
Когда один и тот же тенант доступен одновременно по адресам acme.platform.io и acme.com, поисковые боты пессимизируют обе версии из-за дублирования страниц.
Как избежать: Генерируйте канонические метатеги через generateMetadata с указанием кастомного домена в качестве основного: <link rel="canonical" href="https://acme.com/..." /> и настраивайте 301-редирект с системного поддомена на кастомный при его подключении.