Voltar ao blog
nuxtvuearquiteturasupabasemultitenantpiniatypescriptsaas

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

Como estruturei a arquitetura inicial de um SaaS multitenant com Nuxt 3 e Supabase

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_id para 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:

txt
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:

TabelaPapel na arquitetura
empresasGuarda os dados públicos do tenant, como nome, slug, logo e cores
profilesRelaciona o usuário autenticado a uma empresa
tabelas com empresa_idGuardam 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:

txt
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:

json
{
  "primary": "#2563ab",
  "secondary": "#f97316"
}

A tabela profiles conecta um usuário autenticado a uma empresa:

txt
profiles
- id
- empresa_id

Já as tabelas que possuem dados de uma empresa seguem a mesma lógica:

txt
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.

sql
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:

sql
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:

txt
pages/
├── index.vue
├── 404.vue
└── [slug]/
    └── index.vue

A rota [slug] representa a vitrine pública de uma empresa.

Exemplo:

txt
link-da-plataforma.com/lari-loja

Nesse caso, o Nuxt entende que:

ts
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.

sql
-- 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.

ts
// 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_slug e 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.

ts
// 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.

ts
// 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:

vue
<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:

json
{
  "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.

ts
// 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.

vue
<!-- 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:

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:

vue
<p class="text-(--company-primary)">
  Vitrine
</p>

Ou:

vue
<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:

txt
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

CamadaResponsabilidade
BancoRelacionar dados por empresa_id e proteger acesso com RLS
RPCResolver uma empresa a partir do slug
MiddlewareLer o slug da URL e carregar a empresa ativa
StoreGuardar a empresa ativa para a aplicação
Page [slug]/index.vueConsumir a empresa e renderizar a vitrine
Composable useEmpresaTheme()Transformar colors em CSS variables seguras
Layout catalog.vueAplicar as variáveis de tema na árvore da vitrine
ComponentesUsar 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 colors da empresa são transformadas em --company-primary e --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.


Leituras relacionadas

Feito com e Vue.js
2026 © Larissa Santos