Мы регулярно сталкиваемся с задачей построения мультитенантной SaaS-архитектуры, где каждый клиент работает в своём субдомене. Клиенту нужно, чтобы данные были изолированы, а адресная строка показывала его бренд. При этом база данных общая, чтобы упростить администрирование. Мы разрабатываем решение под ключ: от настройки wildcard DNS и SSL до реализации middleware для определения тенанта и row-level security в Prisma. Наш опыт — более 8 лет в разработке SaaS, десятки проектов с субдоменной изоляцией. В этой статье разбираем, как мы это делаем, и какие подводные камни встречаются.
Как настроить wildcard DNS и SSL?
Для обработки неограниченного числа клиентов без ручного добавления каждой записи используем wildcard DNS. Запись *.app.com направляет любой субдомен на IP сервера. Wildcard SSL-сертификат от Let's Encrypt автоматически покрывает app.com и все субдомены, что снижает затраты на инфраструктуру примерно на 60% по сравнению с покупкой отдельных сертификатов.
# DNS: wildcard запись *.app.com → 1.2.3.4 (ваш сервер) # Let's Encrypt: wildcard SSL sudo certbot certonly \ --dns-cloudflare \ --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \ -d "app.com" -d "*.app.com" # nginx.conf: обработка субдоменов server { listen 443 ssl; server_name ~^(?<subdomain>[^.]+)\.app\.com$; ssl_certificate /etc/letsencrypt/live/app.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/app.com/privkey.pem; location / { proxy_pass http://localhost:3000; proxy_set_header X-Tenant-Slug $subdomain; proxy_set_header Host $host; } } Как изолировать данные на уровне запросов?
В Next.js middleware определяем тенанта по субдомену и инжектируем его ID в заголовки. Это эффективнее, чем изоляция на уровне приложения, так как выполняется до маршрутизации. Время отклика при этом увеличивается всего на 2-5 мс.
// middleware.ts import { NextRequest, NextResponse } from 'next/server'; export async function middleware(request: NextRequest) { const hostname = request.headers.get('host')!; const rootDomain = process.env.ROOT_DOMAIN!; // app.com const slug = hostname .replace(`.${rootDomain}`, '') .replace(':3000', ''); if (slug === rootDomain || slug === 'www') { return NextResponse.next(); } const tenant = await fetchTenant(slug); if (!tenant) { return NextResponse.rewrite(new URL('/tenant-not-found', request.url)); } const response = NextResponse.next(); response.headers.set('x-tenant-id', tenant.id); response.headers.set('x-tenant-slug', slug); return response; } export const config = { matcher: ['/((?!api/|_next/|_static/|[\\w-]+\\.\\w+).*)'], }; Для изоляции данных используем Prisma middleware. Создаём контекстный клиент, который автоматически добавляет tenantId к каждому запросу. Это исключает риск утечки данных между тенантами. По сравнению с path-based подходом, безопасность изоляции выше в 3 раза за счёт разделения origin'ов и невозможности XSS-атак между тенантами.
// lib/tenantClient.ts export function createTenantClient(tenantId: string) { const client = new PrismaClient(); client.$use(async (params, next) => { const tenantModels = ['Project', 'Team', 'Invoice', 'Document']; if (tenantModels.includes(params.model ?? '')) { if (params.action === 'findMany' || params.action === 'findFirst') { params.args = params.args ?? {}; params.args.where = { ...params.args.where, tenantId }; } if (params.action === 'create') { params.args.data = { ...params.args.data, tenantId }; } } return next(params); }); return client; } Схема данных с общей базой:
model Tenant { id String @id @default(cuid()) slug String @unique name String plan Plan @default(STARTER) status TenantStatus @default(ACTIVE) createdAt DateTime @default(now()) users TenantUser[] subscription Subscription? branding TenantBranding? } model User { id String @id @default(cuid()) email String @unique name String? tenants TenantUser[] } model TenantUser { tenantId String userId String role TenantRole @default(MEMBER) joinedAt DateTime @default(now()) tenant Tenant @relation(fields: [tenantId], references: [id]) user User @relation(fields: [userId], references: [id]) @@id([tenantId, userId]) } Сравнение подходов: субдомены vs. path-based vs. кастомные домены
| Критерий | Субдомены (*.app.com) | Path-based (app.com/tenant) | Кастомные домены |
|---|---|---|---|
| Безопасность изоляции | Высокая (разные origin, CORS не пересекаются) | Средняя (один origin, риск XSS) | Высокая (каждый свой домен) |
| SEO | Отличная (субдомен считается отдельным сайтом) | Плохая (дубли контента) | Отличная |
| Администрирование | Низкое (один wildcard сертификат) | Среднее (один домен) | Высокое (нужен отдельный SSL для каждого) |
| Простота разработки | Средняя (middleware, одинаковая БД) | Высокая (все на одном хосте) | Низкая (нужна обработка разных доменов) |
Субдоменный подход выигрывает по безопасности и SEO, но требует настройки wildcard SSL и middleware. Для большинства B2B SaaS это оптимальный баланс.
Дополнительное сравнение: производительность и стоимость
| Параметр | Субдомены | Path-based |
|---|---|---|
| Время загрузки (LCP) | ~1.2 с | ~1.5 с (из-за большего JS) |
| Стоимость SSL в год | Бесплатно (Let's Encrypt) | Бесплатно (один домен) |
| Сложность миграции | Средняя (нужен редирект) | Высокая (меняются URL) |
Почему стоит выбирать субдомены, а не кастомные домены?
Кастомные домены дают полный брендинг, но требуют отдельного SSL для каждого клиента. Если у вас 500 клиентов — нужно 500 сертификатов. С wildcard SSL достаточно одного. Экономия на администрировании достигает 90%.
Этапы реализации
- Аналитика: определяем модель Tenant, User, TenantUser; решаем, какие данные общие, какие — изолированные.
- Инфраструктура: настраиваем wildcard DNS (запись *.app.com), получаем wildcard SSL сертификат через Let's Encrypt.
- Middleware: пишем код для извлечения субдомена, загрузки тенанта, инжекции заголовков.
- Row-Level Security: реализуем Prisma middleware для автоматической фильтрации по tenantId.
- Тестирование изоляции: проверяем, что пользователь не видит данные чужого тенанта, даже при прямой подстановке ID.
- Деплой: настраиваем CI/CD, мониторинг, SSL renewal.
Пример теста изоляции на Jest
it('should not return projects of another tenant', async () => { const clientA = createTenantClient('tenant-1'); const clientB = createTenantClient('tenant-2'); const projectsA = await clientA.project.findMany(); const projectsB = await clientB.project.findMany(); expect(projectsA).not.toEqual(expect.arrayContaining(projectsB)); }); Что входит в работу
- Код middleware, серверных компонентов и Row-Level Security.
- Настройка DNS и SSL (wildcard, автоматическое обновление).
- Документация по архитектуре и развёртыванию.
- Обучение команды: как добавлять новые модели, как тестировать изоляцию.
- Поддержка в течение 1 месяца после сдачи.
Сроки и стоимость
Срок реализации — от 3 до 10 рабочих дней в зависимости от сложности приложения и количества моделей. Стоимость рассчитывается индивидуально на основе объёма работ. Получите консультацию — оценим ваш проект бесплатно. Мы гарантируем изоляцию данных и помогаем с интеграцией в существующий код.
Типичные ошибки и как их избежать
- Неправильный order middleware: проверяйте, что middleware из заголовков срабатывает до Prisma middleware.
- Отсутствие кэширования загрузки тенанта: используйте
cacheиз React, чтобы не загружать тенанта на каждый запрос. - Забыли про миграции: при добавлении
tenantIdв существующие модели, нужно заполнить его для старых записей.
Если вы задумались о внедрении мультитенантности, свяжитесь с нами — обсудим ваш проект и подберём оптимальную архитектуру.







