Pular para o conteúdo principal

Tema multi-cliente (white-label) via data-theme no elemento html — Next.js Supabase

O SuperApp serve vários clientes (associações) e cada um tem sua própria identidade visual. Esta página descreve a arquitetura final de theming white-label já implementada: o proxy resolve o cliente (por domínio ou path), o root layout aplica data-theme no <html> — o que faz até o conteúdo portado (modais, menus) herdar o tema —, e cada site público permanece independente por prefixo de token.

Visão geral: dois modelos de theming

Tema e estrutura são independentes, então o SuperApp tem dois modelos de theming que coexistem:

  1. Login — um único esqueleto de telas, compartilhado por todos os clientes, tematizado por acento (tokens shadcn --primary, --primary-foreground, --ring) sobrescrito por cliente.
  2. Site público — cada cliente tem suas próprias pastas e componentes (não há esqueleto compartilhado), tematizado por um prefixo de token próprio (--<cliente>-*).

Ambos se apoiam num único mecanismo central: o atributo data-theme aplicado no elemento <html>.

Mecanismo central: data-theme no <html>

O fluxo que liga domínio/rota ao tema renderizado é:

Domínio ou path → proxy (proxy.ts) resolve o slug do cliente (resolveClientSlug)
→ seta o header da request `x-client-slug`
→ root layout (app/[locale]/layout.tsx) lê o header
→ renderiza <html data-theme={slug}>
→ portais (Sheet/Dialog/Popover/Select/DropdownMenu no body) herdam o tema

Resolução do slug no proxy

resolveClientSlug(request) em apps/web/proxy.ts determina o cliente atual em três etapas:

  1. Pelo domínio, via DOMAIN_SLUG_MAP[host] (o host tem a porta removida para que abracaf.com.br:3000 e abracaf.com.br resolvam para a mesma entrada).
  2. Pela intranet: no padrão /intranet/home/<account>, o cliente é o próprio account — o slug não está no primeiro segmento (é intranet/home), então derivamos do account (aceito se isValidClient(account)).
  3. Se nada acima resolver, pelo primeiro segmento do path (após o locale) que não seja prefixo de produto, aceito apenas se isValidClient(segment) for verdadeiro.
const segments = getNormalizedPathname(request.nextUrl.pathname)
.split('/')
.filter(Boolean);

// Intranet: /intranet/home/<account> → cliente = account
if (segments[0] === 'intranet' && segments[1] === 'home' && segments[2]) {
return isValidClient(segments[2]) ? segments[2] : null;
}

No topo de proxy(), o slug resolvido é exposto na request:

const clientSlug = resolveClientSlug(request);

if (clientSlug) {
request.headers.set('x-client-slug', clientSlug);
}

Aplicação no root layout

apps/web/app/[locale]/layout.tsx lê o header (getClientSlug() via headers()) e aplica o atributo direto no <html>:

const clientSlug = await getClientSlug(); // headersStore.get('x-client-slug') ?? undefined

return (
<html lang={locale} data-theme={clientSlug} suppressHydrationWarning>
{/* ... */}
</html>
);
dica
Por que no e não num

Sheet, Dialog, Popover, Select e DropdownMenu (Base UI) renderizam via portal direto no document.body, fora de qualquer <div data-theme>. Como o <html> é o ancestral do <body>, colocar data-theme no <html> faz todo conteúdo portado herdar o tema — corrigindo o bug em que um menu dentro de um portal perdia as cores. Como o atributo já vem no HTML renderizado no servidor, também não há flash (FOUC).

Login: esqueleto fixo, tematizado por acento

O login vive sob o segmento [team] (apps/web/app/[locale]/[team]/layout.tsx). É um único esqueleto de telas, igual para todos os clientes, tematizado pelos tokens shadcn de acento, sobrescritos por cliente via blocos [data-theme='<slug>'].

O layout do [team]:

  • Valida o slug contra o registry (getClient(team)) e dá notFound() (404) se for desconhecido.
  • Provê o cliente no contexto via CurrentClientProvider.
  • Aplica data-theme={client.dataTheme} no seu próprio wrapper.

Exemplo de bloco de acento por cliente, em apps/web/styles/theme.abrare.css:

[data-theme='abrare'] {
--primary: oklch(0.52 0.12 160); /* verde esmeralda — botão "Continuar" */
--primary-foreground: oklch(0.98 0 0); /* branco */
--ring: oklch(0.77 0.16 75); /* âmbar — foco */
}

Adicionar um cliente ao login não exige trabalho por rota: basta a entrada no registry, o bloco de acento e o logo. Para o detalhamento do fluxo de login, veja Login Magic-Link.

Site público: independente por cliente

Cada cliente com site público tem suas próprias pastas e componentes — não há esqueleto compartilhado. O tema vem de um prefixo de token próprio (--<cliente>-*), o que evita o problema de N clientes colidirem nos mesmos nomes de token.

O padrão (seguido pela Abracaf em apps/web/styles/theme.abracaf.css, prefixo abc) é:

  1. Definir os tokens do design system globalmente em :root (não escopados em [data-theme]), para que conteúdo portado também os resolva:

    :root {
    --abc-primary-branding: #0b2650;
    --abc-secondary-branding: #00c0ea;
    /* ... demais tokens --abc-* ... */
    }
  2. Mapear os tokens para utilitários Tailwind via um bloco @theme inline, gerando bg-abc-*, text-abc-*, border-abc-*:

    @theme inline {
    --color-abc-primary-branding: var(--abc-primary-branding);
    --color-abc-navy-900: var(--abc-navy-900);
    /* ... */
    }
  3. Os componentes do site usam as classes geradas (bg-abc-*, text-abc-*, etc).

Como cada cliente usa um prefixo distinto, os valores não colidem em :root, os portais funcionam (tokens globais) e não é preciso data-theme para o site público.

Acento do login mora junto, mas separado

Em theme.abracaf.css, os tokens --abc-* do design system ficam em :root (globais), enquanto o acento do login (--primary, --primary-foreground, --ring) fica escopado em [data-theme='abracaf']. São responsabilidades distintas no mesmo arquivo: prefixo global para o site, acento por data-theme para o login.

Como adicionar um cliente

Cliente no login

  1. Adicionar a entrada em apps/web/config/clients.config.ts (slug, name, dataTheme, logoSrc opcional).
  2. Criar o bloco de acento [data-theme='<slug>'] num arquivo apps/web/styles/theme.<slug>.css e importá-lo em apps/web/styles/globals.css.
  3. Fornecer o logo.

O slug chega automaticamente da rota ou do domínio — não há trabalho por rota.

Site público novo

  1. Definir o prefixo próprio --<slug>-* em :root (global).
  2. Adicionar o bloco @theme inline mapeando para --color-<slug>-* (gera bg-<slug>-*, etc).
  3. Construir os componentes do site usando bg-<slug>-* e afins.

Intranet (/intranet/home/[account]/...)

Implementado

Na intranet o cliente não está no primeiro segmento do path (é intranet/home), então o resolveClientSlug tem uma regra dedicada que deriva o cliente do segmento account (/intranet/home/{account} → usa account se for um cliente válido), cobrindo toda a intranet daquele cliente sem hardcode por rota. Assim o <html> recebe data-theme={account} e a intranet herda o tema white-label (inclusive nos portais).

Diferente do login (que só sobrescreve o acento), a intranet reaproveita muitos componentes shadcn "crus" (tabela, badges, selects, bordas). Por isso, no theme.abracaf.css, o bloco [data-theme='abracaf'] mapeia o conjunto completo de tokens semânticos shadcn para a paleta --abc-*--primary, --secondary, --muted, --accent, --border, --input, --destructive, --foreground e um [data-theme='abracaf'].dark correspondente (para não deixar texto escuro em fundo escuro). Recolorir os tokens aqui repinta a UI inteira sem tocar componente por componente.

Token extra: --primary-accent

Quando o protótipo usa um segundo tom da marca (ex.: aba ativa em primary-200 #005192, distinto do botão em primary-branding #0B2650), criamos um token semântico por-tema --primary-accent. O componente consome com fallback — var(--primary-accent, var(--primary)) — para associações sem esse token caírem no --primary delas, preservando o isolamento.

Quando cada modelo se aplica

CasoComo o tema é aplicado
Login e sites com o slug presente na URL (domínio ou path)Automático — o proxy resolve o slug e o <html> recebe o data-theme
Intranet (/intranet/home/{account})O proxy deriva o cliente do account; o data-theme no <html> tematiza inclusive os portais. Tokens shadcn mapeados para --abc-* no bloco [data-theme='abracaf']
Site público de um clientePrefixo de token próprio (--<cliente>-*) global em :root; não depende de data-theme
Rotas internas sem cliente (marketing, admin)Sem data-theme → tema base shadcn

Verificação

  1. Menu mobile (Sheet) e outros overlays de cada cliente: fundo e cores corretos (o portal herda o tema do <html>).

  2. Trocar de cliente (domínio ou rota) muda cores e logo sem flash — confirmar que o data-theme já vem no HTML renderizado no servidor.

  3. Dois clientes com prefixos distintos renderizam cada um com os seus valores, sem colisão em :root.

  4. Rotas internas (sem cliente) seguem no tema base shadcn.

  5. Rodar a verificação do app:

    pnpm --filter web typecheck