Como estruturei a arquitetura inicial de um SaaS multitenant com Nuxt 3 e Supabase
14 de maio de 2026 às 18:12 - Por Larissa Santos

Recentemente, precisei arquitetar um SaaS multitenant para um projeto que tinha um desafio central: permitir que várias empresas utilizassem o mesmo sistema, mas mantendo vitrines, dados e identidades visuais separadas. Antes de pensar nas telas ou nos componentes, a primeira decisão importante foi entender como a aplicação identificaria qual empresa estava sendo acessada e como esse contexto seria compartilhado entre rotas, layout e interface.
Para construir a base do MVP, utilizei Nuxt 3 no frontend, Nuxt UI 4 na construção de componentes, Nuxt SEO para apoiar a otimização das páginas, Supabase como backend principal e Vercel para o deploy. Com essa estrutura, a arquitetura foi organizada para resolver três pontos principais: descobrir o tenant a partir da URL, manter os dados da empresa ativa disponíveis na aplicação e refletir sua identidade visual de forma dinâmica na vitrine pública.
Para isso, a arquitetura foi organizada com:
- um único banco compartilhado;
- uma coluna
empresa_idpara relacionar os dados ao tenant; - RLS no Supabase para isolamento dos dados;
- rotas dinâmicas por slug no Nuxt;
- middleware global para resolver a empresa acessada;
- Pinia para manter a empresa ativa em memória;
- CSS variables para aplicar as cores de cada empresa no layout.
A lógica central é simples: quando o usuário acessa uma URL como /lari-loja, o sistema precisa transformar esse slug em uma empresa real e disponibilizar essa empresa para o restante da aplicação.
A estratégia multitenant
Existem várias formas de estruturar um SaaS multitenant. Uma delas seria criar um banco separado para cada cliente. Para este projeto, essa opção traria complexidade cedo demais.
Por isso, optei por uma arquitetura baseada em row-level multitenancy. Isso significa que os dados das empresas vivem nas mesmas tabelas, mas cada registro possui uma relação com a empresa dona daquele dado.
O centro dessa estratégia é o campo:
empresa_id
Esse campo aparece nas tabelas que pertencem a uma empresa e permite que o banco saiba a qual tenant cada informação pertence.
Com essa decisão, a aplicação não precisa trocar de banco quando muda de empresa. Ela muda apenas o contexto ativo.
Como o banco foi estruturado
A base da arquitetura começa com três ideias principais no banco:
| Tabela | Papel na arquitetura |
|---|---|
empresas | Guarda os dados públicos do tenant, como nome, slug, logo e cores |
profiles | Relaciona o usuário autenticado a uma empresa |
tabelas com empresa_id | Guardam dados pertencentes a uma empresa específica |
A tabela empresas funciona como a origem do tenant público.
Ela precisa ter, no mínimo, algo como:
empresas
- id
- nome
- slug
- logo_url
- descricao
- colors
O campo slug é usado na URL pública, como será explicado posteriormente.
O campo colors guarda a identidade visual da empresa:
{
"primary": "#2563ab",
"secondary": "#f97316"
}
A tabela profiles conecta um usuário autenticado a uma empresa:
profiles
- id
- empresa_id
Já as tabelas que possuem dados de uma empresa seguem a mesma lógica:
itens
- id
- empresa_id
- nome
- ...
O ponto importante não é listar todos os campos. O ponto é a decisão: todo dado que pertence a uma empresa precisa carregar empresa_id.
Como o banco descobre a empresa do usuário
Para a parte autenticada do sistema, foi criada a função get_minha_empresa_id().
Ela permite que o próprio banco descubra qual empresa pertence ao usuário logado.
create or replace function get_minha_empresa_id()
returns uuid
language sql
stable
security definer
as $$
select empresa_id
from profiles
where id = auth.uid();
$$;
O auth.uid() vem do Supabase Auth e representa o usuário autenticado na requisição atual.
A função usa esse ID para buscar o empresa_id correspondente na tabela profiles.
Com isso, as policies do RLS não precisam confiar em um empresa_id enviado pelo frontend. O banco consulta o contexto da sessão e valida o acesso por conta própria.
Um exemplo reduzido de policy seria:
using (empresa_id = get_minha_empresa_id())
with check (empresa_id = get_minha_empresa_id())
O using filtra o que o usuário pode acessar.
O with check impede que ele crie ou altere registros usando o empresa_id de outra empresa.
Essa foi uma decisão central da arquitetura: o isolamento dos dados não fica dependente da interface. Essa responsabilidade fica no banco.
O roteamento por slug nas páginas públicas
Nas páginas públicas, a empresa ativa é identificada pela URL através de um slug. Esse slug funciona como a chave inicial para localizar o tenant no banco, carregar seus dados e disponibilizar esse contexto para o restante da aplicação.
A estrutura pensada foi:
pages/
├── index.vue
├── 404.vue
└── [slug]/
└── index.vue
A rota [slug] representa a vitrine pública de uma empresa.
Exemplo:
link-da-plataforma.com/lari-loja
Nesse caso, o Nuxt entende que:
slug = 'lari-loja'
Mas o slug sozinho não resolve o problema. Ele é apenas texto na URL.
Ainda é necessário descobrir se existe uma empresa cadastrada com esse slug e carregar seus dados.
Essa responsabilidade ficou no middleware.
Middleware global: transformando slug em empresa
O middleware global conecta a URL ao tenant.
Ele roda antes da página renderizar, lê o slug, consulta o Supabase e salva a empresa encontrada na store.
Para que tudo funcione, existe uma função no banco responsável por resolver a empresa a partir do slug recebido pela URL.
-- resolver_empresa_por_slug(p_slug text)
declare
v_empresa json;
begin
select json_build_object(
'id', e.id,
'slug', e.slug,
'nome', e.nome,
'descricao', e.descricao,
[...]
'colors', e.colors
)
into v_empresa
from empresas e
where e.slug = lower(trim(p_slug))
and e.is_ativo = true;
return v_empresa;
end;
Essa função recebe o slug, normaliza o valor com lower(trim(p_slug)) e busca apenas empresas ativas. O retorno é um JSON com os dados públicos que a vitrine precisa consumir, como nome, descrição, logo e cores.
Com isso, o middleware não precisa conhecer a estrutura completa da tabela empresas. Ele apenas envia o slug para a RPC e recebe uma versão pública da empresa.
// middleware/tenant.global.ts
const rotasReservadas = ['admin', 'login', 'cadastro', 'checkout', '404'] // Lista de rotas reservadas
export default defineNuxtRouteMiddleware(async to => {
const slugParam = to.params.slug // Captura o parâmetro slug da URL atual
if (!slugParam || Array.isArray(slugParam)) return // Valida o slug e interrompe middleware
const slug = slugParam // Atribiu o slug como uma string simples
if (rotasReservadas.includes(slug)) return // Se o slug for uma rota reservada, não tenta buscar empresa
const empresaStore = useEmpresaStore() // Acessa a store responsável por guardar a empresa ativa
// Se a empresa atual já estiver carregada para esse mesmo slug, evita uma nova consulta ao banco
if (empresaStore.empresa?.slug === slug) return
// Cria o cliente do Supabase para realizar a chamada ao backend
const supabase = useSupabaseClient()
// Chama a função RPC no Supabase para buscar a empresa correspondente ao slug da URL
const { data, error } = await supabase.rpc('resolver_empresa_por_slug', {
p_slug: slug
})
// Se ocorrer erro ou nenhuma empresa for encontrada, redireciona para a página 404
if (error || !data) {
return navigateTo('/404')
}
// Salva os dados da empresa encontrada na store
empresaStore.definir(data as EmpresaPublica)
})
Esse middleware tem uma responsabilidade bem específica: resolver o tenant da rota atual.
- Ele não aplica tema;
- Ele não renderiza página;
- Ele não monta catálogo;
- Ele apenas pega o slug, consulta a função
resolver_empresa_por_sluge salva o resultado na store;
Essa separação deixa a rota [slug]/index.vue mais simples, porque ela não precisa conhecer os detalhes de como a empresa foi encontrada.
Salvando a empresa ativa na store
Depois que o middleware encontra a empresa, os dados precisam ficar disponíveis para o restante da aplicação.
Para isso, foi criada uma store com Pinia.
// stores/useEmpresaStore.ts
export const useEmpresaStore = defineStore('empresa', () => {
const empresa = ref<EmpresaPublica | null>(null)
const nomeEmpresa = computed(() => empresa.value?.nome ?? '')
function definir(dados: EmpresaPublica) {
empresa.value = dados
}
function limpar() {
empresa.value = null
}
return {
empresa,
nomeEmpresa,
definir,
limpar
}
})
- A store não busca dados;
- Não chama Supabase;
- Não conhece a rota;
Ela apenas guarda a empresa ativa. Essa é a vantagem de separar as camadas: o middleware resolve, a store armazena e as páginas consomem.
Consumindo a store na rota [slug]/index.vue
Com a empresa já salva na store, a página da vitrine consome esse contexto sem repetir a lógica de resolução do slug.
// pages/[slug]/index.vue
definePageMeta({ layout: 'catalog' })
const empresaStore = useEmpresaStore() // Acessa a store da empresa
const empresa = computed(() => empresaStore.empresa) // Cria uma referência reativa para a empresa ativa
// Define os metadados SEO com base na empresa ativa
useSeoMeta({
title: computed(() => empresa.value?.nome || ''),
description: computed(() => empresa.value?.descricao || ''),
ogTitle: computed(() => empresa.value?.nome || ''),
ogDescription: computed(() => empresa.value?.descricao || ''),
ogImage: computed(() => empresa.value?.logo_url || '')
})
O papel dessa página é consumir o tenant ativo e renderizar a experiência pública daquela empresa, sem buscar tudo novamente. Essa responsabilidade pertence ao middleware.
Na page, o uso fica direto:
<template>
<section>
<p class="text-sm font-medium text-(--company-primary)">Vitrine</p>
<h1>{{ empresa?.nome }}</h1>
</section>
</template>
A página consome a empresa ativa vinda da store. Ela não precisa saber todo o caminho feito até essa empresa chegar ali.
Identidade visual por empresa
Além de resolver qual empresa está ativa, a arquitetura também precisava refletir a identidade visual de cada tenant.
A decisão foi salvar as cores no campo colors da tabela empresas.
Exemplo:
{
"primary": "#2563ab",
"secondary": "#f97316"
}
Essas cores são carregadas junto com a empresa no middleware, salvas na store e depois aplicadas no layout da vitrine.
Composable de tema da empresa
Para evitar lógica repetida, foi criado um composable específico para transformar as cores da empresa em CSS variables.
O useEmpresaTheme() centraliza a leitura e validação das cores.
// composables/useEmpresaTheme.ts
import type { CSSProperties } from 'vue'
// Define um tipo para aceitar propriedades CSS comuns
// e também variáveis CSS customizadas, como --company-primary
type CSSVars = CSSProperties & Record<`--${string}`, string>
// Valida se a cor recebida está no formato hexadecimal
function isHexColor(color?: string | null) {
if (!color) return false
return /^#([0-9A-F]{3}){1,2}$/i.test(color)
}
export function useEmpresaTheme() {
const empresaStore = useEmpresaStore() // Acessa a store da empresa ativa
// Define a cor primária da empresa
const primary = computed(() => {
const color = empresaStore.empresa?.colors?.primary
return isHexColor(color) ? color : '#2563ab'
})
// Define a cor secundária da empresa
const secondary = computed(() => {
const color = empresaStore.empresa?.colors?.secondary
return isHexColor(color) ? color : '#f97316'
})
// Monta as variáveis CSS que serão aplicadas no layout
const themeStyle = computed<CSSVars>(() => ({
'--company-primary': primary.value,
'--company-secondary': secondary.value
}))
// Expõe as cores e o objeto de estilo para uso no layout
return {
primary,
secondary,
themeStyle
}
}
Esse composable evita que a validação de cor fique espalhada em várias páginas. Se a empresa tiver uma cor válida, o sistema usa a cor cadastrada, se não tiver, usa uma cor padrão. Assim, a interface nunca fica sem tema.
Aplicando as cores no layout/catalog.vue
O layout da vitrine é o melhor lugar para aplicar o tema, porque ele envolve tudo que pertence à experiência pública da empresa.
<!-- layouts/catalog.vue -->
<template>
<div
class="min-h-screen bg-neutral-50 text-neutral-900 dark:bg-neutral-950 dark:text-white"
>
<main :style="themeStyle"> <!-- Estilo será herdado em todas as paginas e componentes -->
<SharedHeader />
<slot />
</main>
</div>
</template>
<script setup lang="ts">
const { themeStyle } = useEmpresaTheme() // Carrega o tema aqui
</script>
Quando o middleware salva a empresa na store, o composable lê as cores, monta o themeStyle e o layout aplica as variáveis CSS:
--company-primary
--company-secondary
A partir daí, qualquer componente dentro do layout pode usar as cores da empresa sem receber props e sem consultar a store diretamente.
Exemplo:
<p class="text-(--company-primary)">
Vitrine
</p>
Ou:
<button style="background-color: var(--company-primary)">
Ver detalhes
</button>
A cor vem do banco através da empresa obtida no banco pelo middleware, é salva na store, tratada no composable e chega ao componente como CSS variable.
Esse fluxo evita acoplamento desnecessário.
O fluxo completo da arquitetura
A arquitetura inicial funciona assim:
1. Usuário acessa /lari-loja
2. O Nuxt identifica o parâmetro [slug]
slug = "lari-loja"
3. O middleware tenant.global.ts roda
valida se o slug deve ser tratado como tenant
chama resolver_empresa_por_slug no Supabase
4. A empresa encontrada é salva na useEmpresaStore
empresaStore.definir(data)
5. A página [slug]/index.vue consome a store
usa nome, descrição, logo e dados públicos da empresa
6. O layout catalog.vue chama useEmpresaTheme
lê empresa.colors
aplica --company-primary e --company-secondary
7. Os componentes usam as variáveis CSS
sem precisar saber qual empresa está ativa
Esse fluxo mantém cada responsabilidade em seu lugar.
A divisão de responsabilidades
| Camada | Responsabilidade |
|---|---|
| Banco | Relacionar dados por empresa_id e proteger acesso com RLS |
| RPC | Resolver uma empresa a partir do slug |
| Middleware | Ler o slug da URL e carregar a empresa ativa |
| Store | Guardar a empresa ativa para a aplicação |
Page [slug]/index.vue | Consumir a empresa e renderizar a vitrine |
Composable useEmpresaTheme() | Transformar colors em CSS variables seguras |
Layout catalog.vue | Aplicar as variáveis de tema na árvore da vitrine |
| Componentes | Usar as variáveis sem conhecer a lógica de tenant |
Essa separação é o que deixa a arquitetura sustentável:
- Se amanhã a forma de resolver o tenant mudar, o ajuste fica no middleware ou na RPC.
- Se amanhã o formato das cores mudar, o ajuste fica no
useEmpresaTheme(). - Se amanhã a vitrine mudar visualmente, o ajuste fica na page ou nos componentes.
Isso é o principio SOLID sendo aplicado na arquitetura frontend.
Decisões técnicas
- Nuxt 3 foi usado como base do frontend por organizar bem rotas, layouts, middleware, SSR e composables. Essa estrutura permite criar uma arquitetura onde o slug da URL identifica a empresa ativa sem misturar essa responsabilidade diretamente nas páginas.
- Supabase foi utilizado como backend principal, reunindo banco PostgreSQL, autenticação, funções RPC e Row Level Security. A decisão arquitetural foi concentrar nele a base do multitenant: dados relacionados por
empresa_id, tenant resolvido por slug no banco e isolamento reforçado com políticas de acesso. - Pinia foi usado para manter a empresa ativa disponível globalmente depois que o middleware resolve o slug. Assim, layouts, páginas e composables conseguem consumir o contexto do tenant sem repetir chamadas ao Supabase.
- Nuxt UI 4 faz parte da base visual do projeto e ajuda a construir interfaces com componentes prontos e consistentes, mantendo uma estrutura visual sólida para a vitrine pública.
- Tailwind CSS foi usado para controlar o estilo da aplicação de forma flexível e produtiva. Em conjunto com CSS variables, ele permite aplicar as cores de cada empresa diretamente na interface sem criar estilos separados por tenant.
- Composables foram usados para isolar regras reutilizáveis, como o tratamento das cores no
useEmpresaTheme(). Isso evita espalhar validações e transformações visuais dentro das páginas ou componentes. - CSS Variables foram aplicadas para refletir a identidade visual de cada tenant. As cores cadastradas no campo
colorsda empresa são transformadas em--company-primarye--company-secondary, permitindo que os componentes usem o tema da empresa sem depender diretamente da store. - TypeScript ajuda a dar mais previsibilidade aos dados da aplicação, principalmente no contrato da empresa pública consumida pelo middleware, store, layout e página dinâmica.
Conclusão
A arquitetura inicial do projeto foi construída em cima de uma ideia simples: o slug define o tenant, a store compartilha o contexto e o layout reflete a identidade visual da empresa.
- O banco fica como base da separação por
empresa_id. - O middleware transforma a URL em contexto real.
- A store mantém esse contexto disponível.
- O layout aplica as cores da empresa.
- E os componentes apenas consomem o resultado.
No fim, o mais importante não foi apenas fazer funcionar, foi definir onde cada responsabilidade deveria morar e porque.
Essa é a diferença entre uma implementação que só resolve o problema de hoje e uma arquitetura que continua compreensível quando o projeto começa a crescer. Isso diferencia um desenvolvedor que somente escreve código, de um que arquiteta sistemas completos e tem visão de projeto.
A pergunta que guia cada decisão
A pergunta que guiou as principais decisões foi: se eu precisar mudar isso daqui a seis meses, qual é o menor número de arquivos que vou precisar tocar?
Quando a resposta é "um", a separação está certa. Quando a resposta é "vários espalhados", alguma responsabilidade está no lugar errado e isso pode comprometer o projeto ou torna-lo de dficil manutenção no futuro.