Ми регулярно стикаємося з задачею побудови мультитенантної SaaS-архітектури, де кожен клієнт працює в своєму субдомені. Клієнту потрібно, щоб дані були ізольовані, а адресний рядок показував його бренд. При цьому база даних спільна, щоб спростити адміністрування. Ми розробляємо рішення під ключ: від налаштування wildcard DNS і SSL до реалізації middleware для визначення тенанта та row-level security в Prisma. Наш досвід — понад 8 років у розробці SaaS, десятки проєктів з піддоменною ізоляцією. У цій статті розбираємо, як ми це робимо, і які підводні камені зустрічаються.
Мультитенантний SaaS: налаштування wildcard DNS і SSL
Для обробки необмеженої кількості клієнтів без ручного додавання кожного запису використовуємо wildcard DNS. Запис *.app.com направляє будь-який субдомен на IP сервера. Wildcard SSL-сертифікат від Let's Encrypt автоматично покриває app.com і всі субдомени, що знижує витрати на інфраструктуру приблизно на 60% порівняно з покупкою окремих сертифікатів. Економія для 500 клієнтів: $0 за сертифікат (Let's Encrypt безкоштовно) замість $50/рік за кожен кастомний домен — загалом $25 000 на рік.
# 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 на рік | $0 (Let's Encrypt) | $50 (один домен) |
| Складність міграції | Середня (потрібен редирект) | Висока (змінюються URL) |
Чому варто обирати субдомени, а не кастомні домени?
Кастомні домени дають повний брендинг, але вимагають окремого SSL для кожного клієнта. Якщо у вас 500 клієнтів — потрібно 500 сертифікатів, що коштує $25 000/рік. З 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 робочих днів залежно від складності застосунку та кількості моделей. Вартість розраховується індивідуально на основі обсягу робіт. Отримайте консультацію — оцінимо ваш проєкт безкоштовно. Ми гарантуємо ізоляцію даних і допомагаємо з інтеграцією в існуючий код.
Типові помилки та як їх уникнути
- Неправильний порядок middleware: перевіряйте, що middleware із заголовків спрацьовує до Prisma middleware.
- Відсутність кешування завантаження тенанта: використовуйте
cacheз React, щоб не завантажувати тенанта на кожен запит. - Забули про міграції: при додаванні
tenantIdв існуючі моделі потрібно заповнити його для старих записів.
Якщо ви задумалися про впровадження мультитенантності, зв'яжіться з нами — обговоримо ваш проєкт і підберемо оптимальну архітектуру.







