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:
- Login — um único esqueleto de telas, compartilhado por todos os clientes, tematizado por acento (tokens shadcn
--primary,--primary-foreground,--ring) sobrescrito por cliente. - 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:
- Pelo domínio, via
DOMAIN_SLUG_MAP[host](o host tem a porta removida para queabracaf.com.br:3000eabracaf.com.brresolvam para a mesma entrada). - Pela intranet: no padrão
/intranet/home/<account>, o cliente é o próprioaccount— o slug não está no primeiro segmento (éintranet/home), então derivamos doaccount(aceito seisValidClient(account)). - 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>
);
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) é:
-
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-* ... */} -
Mapear os tokens para utilitários Tailwind via um bloco
@theme inline, gerandobg-abc-*,text-abc-*,border-abc-*:@theme inline {--color-abc-primary-branding: var(--abc-primary-branding);--color-abc-navy-900: var(--abc-navy-900);/* ... */} -
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.
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
- Adicionar a entrada em
apps/web/config/clients.config.ts(slug,name,dataTheme,logoSrcopcional). - Criar o bloco de acento
[data-theme='<slug>']num arquivoapps/web/styles/theme.<slug>.csse importá-lo emapps/web/styles/globals.css. - Fornecer o logo.
O slug chega automaticamente da rota ou do domínio — não há trabalho por rota.
Site público novo
- Definir o prefixo próprio
--<slug>-*em:root(global). - Adicionar o bloco
@theme inlinemapeando para--color-<slug>-*(gerabg-<slug>-*, etc). - Construir os componentes do site usando
bg-<slug>-*e afins.
Intranet (/intranet/home/[account]/...)
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.
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
| Caso | Como 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 cliente | Prefixo 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
-
Menu mobile (Sheet) e outros overlays de cada cliente: fundo e cores corretos (o portal herda o tema do
<html>). -
Trocar de cliente (domínio ou rota) muda cores e logo sem flash — confirmar que o
data-themejá vem no HTML renderizado no servidor. -
Dois clientes com prefixos distintos renderizam cada um com os seus valores, sem colisão em
:root. -
Rotas internas (sem cliente) seguem no tema base shadcn.
-
Rodar a verificação do app:
pnpm --filter web typecheck