Реализация мультитенантной архитектуры SaaS-приложения (Subdomain Tenancy)

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация мультитенантной архитектуры SaaS-приложения (Subdomain Tenancy)
Сложная
~2-4 недели
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

SaaS мультитенантность через субдомены

Субдоменная архитектура: каждый клиент получает {slug}.app.com. Пользователь видит свой бренд в адресной строке, данные изолированы на уровне запросов.

Wildcard DNS и SSL

# 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.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

  // Локально: acme.localhost:3000
  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+).*)'],
};
// lib/tenant.ts: кэшированный доступ к тенанту
import { headers } from 'next/headers';
import { cache } from 'react';

export const getCurrentTenant = cache(async () => {
  const tenantId = headers().get('x-tenant-id');
  if (!tenantId) return null;

  return db.tenant.findUnique({
    where: { id: tenantId },
    include: { branding: true, subscription: true }
  });
});

Схема данных: общая БД

model Tenant {
  id        String        @id @default(cuid())
  slug      String        @unique  // acme
  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  // email глобально уникален
  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])
}

Row-Level Security: изоляция данных

// Каждый Prisma запрос фильтруется по tenantId
// Паттерн: создаём контекстный клиент

export function createTenantClient(tenantId: string) {
  const client = new PrismaClient();

  // Middleware: автоматически добавляем tenantId к запросам
  client.$use(async (params, next) => {
    // Модели с tenantId
    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, // всегда сохраняем с текущим tenantId
        };
      }
    }

    return next(params);
  });

  return client;
}

// Использование в Server Components
export default async function ProjectsPage() {
  const tenant = await getCurrentTenant();
  const db = createTenantClient(tenant!.id);

  // tenantId добавляется автоматически — нет риска утечки данных
  const projects = await db.project.findMany({
    orderBy: { createdAt: 'desc' }
  });

  return <ProjectsList projects={projects} />;
}

Переключение между тенантами

Один пользователь может быть участником нескольких организаций:

// components/TenantSwitcher.tsx
'use client';
import { useRouter } from 'next/navigation';

export function TenantSwitcher({
  currentSlug,
  tenants,
}: {
  currentSlug: string;
  tenants: Array<{ slug: string; name: string; logoUrl?: string }>;
}) {
  const router = useRouter();

  const switchTenant = (slug: string) => {
    // Перенаправляем на субдомен другого тенанта
    const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN!;
    window.location.href = `https://${slug}.${rootDomain}/dashboard`;
  };

  return (
    <select
      value={currentSlug}
      onChange={e => switchTenant(e.target.value)}
    >
      {tenants.map(t => (
        <option key={t.slug} value={t.slug}>{t.name}</option>
      ))}
    </select>
  );
}

Настройка субдоменного мультитенанта с Prisma middleware и wildcard SSL — 3–5 рабочих дней.