Реалізація мультитенантної архітектури 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
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+).*)'],
};
// lib/tenant.ts: кешована доступ до тенанта
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
  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])
}

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

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, // завжди зберігаємо з поточним tenantId
        };
      }
    }

    return next(params);
  });

  return client;
}

export default async function ProjectsPage() {
  const tenant = await getCurrentTenant();
  const db = createTenantClient(tenant!.id);

  const projects = await db.project.findMany({
    orderBy: { createdAt: 'desc' }
  });

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

Переключення між тенантами

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 робочих днів.