Por Que Multi-Tenancy Baseada em Subdomínio

Vamos detalhar isso. Você geralmente tem três opções ao lidar com roteamento multi-tenant em aplicativos SaaS:

Padrão Exemplo Isolamento SEO Experiência do Usuário
Baseado em caminho app.com/tenant-a/dashboard Baixo Autoridade de domínio compartilhada Sente-se como uma plataforma compartilhada
Baseado em subdomínio tenant-a.app.com Médio Autoridade de subdomínio Sente-se como um aplicativo dedicado
Domínio customizado app.tenant-a.com Alto Autoridade de domínio completa Sente-se totalmente customizado

Por que frequentemente optamos por subdomínios? Bem, é aquela situação de Ouro Cacheado—perfeita para 95% dos casos. O tenant obtém sua própria URL marcada (acme.seuapp.com), e a complexidade? Gerenciável. Além disso, você não fica preso quando eles querem fazer upgrade para um domínio customizado depois. Sente-se pessoal o suficiente para o tenant e mantém sua pilha de tecnologia longe de se tornar uma máquina de Rube Goldberg.

Mas aqui está o problema: comece com subdomínios e ofereça esses domínios customizados como um recurso premium. Com middleware Next.js, tudo isso pode fluir através de um pipeline bem organizado. Fale sobre eficiência, certo?

Middleware Next.js de Subdomínio Coringa para SaaS Multi-Tenant

Visão Geral da Arquitetura

Imagine cada solicitação entrando em seu aplicativo como se estivesse em uma correia transportadora. A primeira coisa que encontra? Seu middleware Next.js. Este código confiável extrai o subdomínio, descobre a qual tenant pertence e então reescreve o caminho interno ou sinaliza essa solicitação com headers que seu aplicativo pode usar. Moleza!

Solicitação: acme.seuapp.com/dashboard
    ↓
Middleware: Extrair hostname → resolver tenant → injetar headers
    ↓
Reescrever: /dashboard → /[tenant]/dashboard (reescrita interna)
    ↓
Página: Lê tenant de params ou headers, busca dados específicos do tenant

O que está realmente acontecendo aqui é um truque de mágica—reescritas de middleware são invisíveis aos usuários. Seu navegador ainda exibe com orgulho acme.seuapp.com/dashboard, enquanto nos bastidores, Next.js realmente roteia para /acme/dashboard.

Estrutura de Diretórios

Aqui está um vislumbre de como seu projeto pode parecer:

├── middleware.ts
├── app/
│   ├── [tenant]/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── dashboard/
│   │   │   └── page.tsx
│   │   └── settings/
│   │       └── page.tsx
│   └── api/
│       └── tenant/
│           └── route.ts
├── lib/
│   ├── tenant.ts
│   └── middleware-utils.ts

Configurando DNS Coringa

Antes de codificar uma única linha, você precisa lidar com DNS.

Vercel

Este é simples: vá para as configurações do seu projeto e adicione um domínio coringa:

*.seuapp.com
seuapp.com

E não se preocupe, Vercel faz o trabalho pesado com certificados SSL para você. A partir de 2025, você precisará de pelo menos um plano Pro ($20/mês por membro da sua equipe) já que planos Hobby não têm sorte aqui.

Cloudflare

Cloudflare funciona bem com roteamento coringa. Configure um registro A assim:

Type: A
Name: *
Content: <seu-ip-servidor>
Proxy: Sim (nuvem laranja)

E se você está na turma Vercel, troque por um registro CNAME:

Type: CNAME
Name: *
Content: cname.vercel-dns.com
Proxy: DNS only (nuvem cinza)

Por que cinza? Vercel lida com SSL, e o proxy de Cloudflare não funciona bem lá. Neutro é seu amigo.

Self-Hosted (Nginx)

server {
    listen 80;
    server_name *.seuapp.com;
    
    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Para SSL, você precisará de um certificado coringa do Let's Encrypt. Seu desafio DNS-01 e plugin Certbot tornam isso mais sensato do que parece.

Construindo o Middleware

Ok, você quer código? Aqui está o middleware que foi testado em batalha. Vou poupar você da glória de cada ponto-e-vírgula, mas acredite em mim, é ouro.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

// Domínios que NÃO devem ser tratados como subdomínios de tenant
const RESERVED_SUBDOMAINS = new Set([
  'www',
  'api',
  'admin',
  'app',
  'mail',
  'blog',
  'docs',
  'status',
]);

const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'seuapp.com';

export const config = {
  matcher: [
    /*
     * Corresponder todos os caminhos exceto:
     * - _next/static (arquivos estáticos)
     * - _next/image (otimização de imagem)
     * - favicon.ico
     * - arquivos da pasta public
     */
    '/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*)',
  ],
};

export default async function middleware(req: NextRequest) {
  const url = req.nextUrl;
  const hostname = req.headers.get('host') || '';
  
  // Remover porta para desenvolvimento local
  const currentHost = hostname.replace(/:\d+$/, '');
  
  // Verificar se este é o domínio raiz ou www
  if (
    currentHost === ROOT_DOMAIN ||
    currentHost === `www.${ROOT_DOMAIN}` ||
    currentHost === 'localhost'
  ) {
    // Este é o site de marketing / página de landing
    return NextResponse.next();
  }
  
  // Extrair subdomínio
  let tenant: string | null = null;
  
  if (currentHost.endsWith(`.${ROOT_DOMAIN}`)) {
    const subdomain = currentHost.replace(`.${ROOT_DOMAIN}`, '');
    
    if (RESERVED_SUBDOMAINS.has(subdomain)) {
      return NextResponse.next();
    }
    
    tenant = subdomain;
  } else {
    // Isto pode ser um domínio customizado
    tenant = await resolveCustomDomain(currentHost);
  }
  
  if (!tenant) {
    // Domínio desconhecido — redirecionar para site principal
    return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
  }
  
  // Reescrever para caminho específico do tenant
  const tenantUrl = new URL(`/${tenant}${url.pathname}`, req.url);
  tenantUrl.search = url.search;
  
  const response = NextResponse.rewrite(tenantUrl);
  
  // Injetar informações do tenant como headers para consumo downstream
  response.headers.set('x-tenant-slug', tenant);
  response.headers.set('x-tenant-domain', currentHost);
  
  return response;
}

async function resolveCustomDomain(domain: string): Promise<string | null> {
  try {
    // Em produção, cachear isto agressivamente
    const res = await fetch(
      `${process.env.INTERNAL_API_URL}/api/domains/resolve?domain=${domain}`,
      {
        headers: { Authorization: `Bearer ${process.env.INTERNAL_API_KEY}` },
        next: { revalidate: 300 },
      }
    );
    
    if (!res.ok) return null;
    
    const data = await res.json();
    return data.tenantSlug || null;
  } catch {
    return null;
  }
}

O Padrão Matcher É Tudo

Você quer velocidade? Esse regex config.matcher é seu melhor amigo. Sem ele, cada solicitação arrasta middleware junto—ativos estáticos, imagens, tudo. E isso é uma via única para +200ms de latência extra. Ninguém quer isso. Olhe isto: /((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*). Isto sozinho cortou nossa latência P95 em 40%! Sim, é meio um grande negócio.

Por Que Reescrever em vez de Redirecionar?

Reescrever não mexe no que os usuários veem em seu navegador—estamos falando de navegação tranquila. Redirecionamentos? Esses são como pedir aos seus usuários para confiar que você os levará ao lugar certo—outra URL na caixa. Fique com rewrite() para uma experiência do usuário sem drama.

Middleware Next.js de Subdomínio Coringa para SaaS Multi-Tenant - arquitetura

Estratégias de Resolução de Tenant

O middleware acima extrai tenants de subdomínios como um profissional. Mas existência de tenant? Tem que confirmar isso. Diferentes abordagens para diferentes folkses quando se trata de estratégias.

Estratégia 1: Busca em Banco de Dados no Middleware

Aqui está a pegadinha: o middleware Next.js roda no Edge Runtime, o que significa dizer adeus a muitas APIs confortáveis do Node.js.

// Funciona com Prisma Accelerate ou @prisma/client/edge
import { PrismaClient } from '@prisma/client/edge';

const prisma = new PrismaClient();

async function resolveTenant(slug: string) {
  return prisma.tenant.findUnique({
    where: { slug },
    select: { id: true, slug: true, plan: true },
  });
}

Estratégia 2: Busca em KV Store

Esta é minha preferida. Armazene seus slugs em um KV store como Vercel KV, Upstash Redis ou Cloudflare KV. Buscas de Edge em 1-5ms significam atraso negligenciável.

import { kv } from '@vercel/kv';

async function resolveTenant(slug: string) {
  const tenant = await kv.get(`tenant:${slug}`);
  return tenant as TenantConfig | null;
}

Estratégia 3: Lista Estática de Permissões

Operação pequena? Listas estáticas de permissões podem ser seu amigo—arquivos JSON em tempo de construção que mantêm as coisas tight e zero-network.

import tenants from './tenants.json';

const tenantMap = new Map(tenants.map(t => [t.slug, t]));

function resolveTenant(slug: string) {
  return tenantMap.get(slug) || null;
}

Reconstruir com ISR ou webhooks para lidar com novos tenants dinamicamente.

Design de Banco de Dados para Multi-Tenancy

Isso é onde as coisas ficam sérias. Dois caminhos principais para escolher:

Banco de Dados Compartilhado com ID de Tenant

Adicione uma coluna tenantId como se estivesse em promoção:

CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);

E sim, RLS do PostgreSQL é sua apólice de seguro:

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

A vida fica mais fácil quando você define contexto:

await db.execute(sql`SELECT set_config('app.current_tenant', ${tenantId}, true)`);

Banco de Dados por Tenant

Quer isolamento de alto nível que se encaixa nos requisitos de conformidade como uma luva? Um banco de dados por tenant é o caminho. Branching do Neon e preços por banco de dados da PlanetScale tornam isto viável, mesmo para pequenas fortunas em 2025.

Suporte a Domínio Customizado

Aqui é onde implantação e vaidade se encontram. Tenants precisam de seus domínios customizados registrados com CNAME apontando em sua direção. E SSL? Cada link clicado precisa brilhar com esse HTTPS.

API de Domínios Customizados da Vercel

A API da Vercel torna isto quase indolor:

async function addCustomDomain(domain: string, tenantId: string) {
  const response = await fetch(
    `https://api.vercel.com/v10/projects/${process.env.VERCEL_PROJECT_ID}/domains`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ name: domain }),
    }
  );
  
  if (response.ok) {
    // Armazenar o mapeamento domínio-para-tenant
    await db.customDomain.create({
      data: { domain, tenantId, verified: false },
    });
  }
  return response.json();
}

Vercel lida com SSL assim que DNS se alinha. Confira seu Kit de Início de Plataformas—é uma implementação modelo.

Cloudflare for SaaS

Isso é ideal para os gerenciadores DNS customizados sofisticados, já que "SSL for SaaS" do Cloudflare traz provisionamento SSL e proxies também.

Performance e Caching

Ao lidar com solicitações, cada nanossegundo conta. O truque é acelerar a resolução de tenant.

Cachear Resolução de Tenant

Você salvará o dia (e servidor) ao cachear buscas de tenant:

import { LRUCache } from 'lru-cache';

const tenantCache = new LRUCache<string, TenantConfig>({
  max: 10000,
  ttl: 1000 * 60 * 5, // 5 minutos
});

async function resolveTenantCached(slug: string): Promise<TenantConfig | null> {
  const cached = tenantCache.get(slug);
  if (cached) return cached;
  
  const tenant = await resolveTenantFromDB(slug);
  if (tenant) {
    tenantCache.set(slug, tenant);
  }
  
  return tenant;
}

Lembre-se, caches em memória não são para Edge Runtime. Vá com algo como Upstash Redis—isso é o que eles fazem melhor.

ISR e Multi-Tenancy

ISR adora multi-tenancy porque é inteligente. Cada tenant obtém uma versão cacheada única devido à reescrita de /dashboard para /acme/dashboard. Nenhuma configuração adicional, apenas aproveite a glória da mágica Next.js.

Configurações de Implantação

Decisões, decisões. Vamos comparar opções de implantação:

Recurso Vercel Pro Cloudflare Pages Self-Hosted (Docker)
Domínios Coringa ✅ (manual)
API de Domínios Customizados ❌ (manual)
Middleware de Edge ❌ (Node only)
SSL Automático ⚠️ Let's Encrypt
Preço $20/seat/mês + uso $5/mês + uso $50-200/mês servidor
Máximo de Domínios Customizados 50+ Ilimitado Ilimitado

Para um lançamento brilhante e rápido-startup, Vercel Pro é seu ingresso. Mas quando esses números sobem—mais usuários, mais solicitações—Cloudflare Pages ou opções self-hosted oferecem flexibilidade e acessibilidade.

Nós navegamos em maravilhas multi-tenant com cada método, então se você está ponderando seu próximo passo, nossas capacidades de desenvolvimento Next.js podem ser seu guia.

Considerações de Segurança

Multi-tenancy é super legal até que não é. Cada tenant espera seus dados seguros—sem vazamentos, sem deslizes.

Checklist de Isolamento de Tenant

  1. Filtrar por ID de Tenant: Nunca confie apenas em URLs. Verificações de back-end importam também.
  2. PostgreSQL RLS: É como ter um detalhe de segurança em seu banco de dados 24/7.
  3. Sanitizar Subdomínios: Nunca escorregue—permita apenas [a-z0-9-]. Evite takeovers de subdomínio.
  4. Rate Limit Por Tenant: Headers dinâmicos para throttle de API podem salvar você.
  5. Log, Auditoria, Revisão: Cada operação de escrita deve dizer "peguei". Confiança incute confiança.
// Validar slug de tenant no middleware
const VALID_SLUG = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;

if (!VALID_SLUG.test(subdomain)) {
  return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}

CORS e Multi-Tenancy

Sua API serve muitos subdomínios, você precisa que CORS não tenha birras:

// app/api/[...route]/route.ts
export async function GET(req: NextRequest) {
  const origin = req.headers.get('origin') || '';
  const isValidOrigin =
    origin.endsWith(`.${ROOT_DOMAIN}`) || 
    await isCustomDomain(origin.replace('https://', ''));
  
  const headers = new Headers();
  if (isValidOrigin) {
    headers.set('Access-Control-Allow-Origin', origin);
    headers.set('Access-Control-Allow-Credentials', 'true');
  }
  
  // ... resto do handler
}

Testando Middleware Multi-Tenant Localmente

Dores de cabeça abundam se o dev local não suporta subdomínios. Aqui está como você sonha novamente.

Opção 1: Editar `/etc/hosts`

# /etc/hosts
127.0.0.1 seuapp.local
127.0.0.1 acme.seuapp.local
127.0.0.1 globex.seuapp.local

Então atualize seu middleware para reconhecer .seuapp.local durante desenvolvimento:

const ROOT_DOMAIN = process.env.NODE_ENV === 'development' 
  ? 'seuapp.local:3000' 
  : 'seuapp.com';

Opção 2: Usar nip.io ou sslip.io

Estos serviços puxam mapeamentos mágicos de IP:

acme.127.0.0.1.nip.io → 127.0.0.1
globex.127.0.0.1.nip.io → 127.0.0.1

Simples, não requer edição de hosts.

Opção 3: Túnel Local com Subdomínios Customizados

Use ngrok (ou similares) para túneis rápidos:

ngrok http 3000 --hostname=*.seu-dominio-dev.ngrok.io

Tessar não deveria ser mais difícil que cenários do mundo real.

Escrevendo Testes de Integração

import { describe, it, expect } from 'vitest';
import { middleware } from './middleware';
import { NextRequest } from 'next/server';

describe('tenant middleware', () => {
  it('reescreve solicitações de subdomínio para caminho de tenant', async () => {
    const req = new NextRequest('https://acme.seuapp.com/dashboard');
    const res = await middleware(req);
    
    expect(res.headers.get('x-middleware-rewrite')).toContain('/acme/dashboard');
    expect(res.headers.get('x-tenant-slug')).toBe('acme');
  });

  it('passa por solicitações de domínio raiz', async () => {
    const req = new NextRequest('https://seuapp.com/');
    const res = await middleware(req);
    
    expect(res.headers.get('x-middleware-rewrite')).toBeNull();
  });

  it('rejeita caracteres inválidos de subdomínio', async () => {
    const req = new NextRequest('https://acme--evil.seuapp.com/');
    const res = await middleware(req);
    
    expect(res.status).toBe(307); // Redirecionar
  });
});

Pensando no quadro maior? Quer seja a escolha entre Next.js ou Astro, considere pular em nossa jornada de insights de camada CMS headless.


Lembre-se, explorar significa perguntar, adaptar e crescer. Nos contacte para uma sessão de brainstorm ou aprofunde-se em nossa precificação se você está mirando parcerias de nível superior.