Voltar ao blog

Construindo um SaaS do zero, parte 3: da tabela do banco para a tela do usuário

Terceira parte da série Construindo um SaaS do zero: como transformar as tabelas do Supabase em telas funcionais, com CRUD, rotas protegidas, estados de carregamento e RLS testada de verdade.

C
Caio Braga
19 de agosto de 2026 · 7 min de leitura
Sumário do artigo
Construindo um SaaS do zero, parte 3: da tabela do banco para a tela do usuário

Na parte 2 você modelou o banco: tabelas, chaves estrangeiras, tipos de coluna e relações. O banco existe, mas ninguém consegue usar ele ainda. Esta terceira parte da série Construindo um SaaS do zero pega essa estrutura e transforma em tela: listar, criar, editar e arquivar registros, com as rotas protegidas e a política de acesso funcionando.

A parte que costuma dar errado não é a query. É tudo em volta dela: o que aparece enquanto o dado carrega, o que aparece quando não existe nenhum registro, o que acontece quando a policy bloqueia a operação e o usuário só vê um botão que não faz nada.

As quatro operações e onde cada uma quebra

Todo CRUD de SaaS tem as mesmas quatro operações. O que muda é o cuidado com cada uma.

Operação Função no SDK Onde costuma quebrar
Listar .select() Traz registro de outro usuário porque a RLS não está ligada
Criar .insert() Falha silenciosa quando a policy de insert não existe
Editar .update() Atualiza a linha errada por falta de .eq('id', id)
Arquivar .update() em coluna de data Delete real apaga histórico que você vai querer depois

Repare no último item. Em produto pago, delete de verdade quase nunca é o que você quer. Uma coluna arquivado_em timestamptz resolve: o registro some da lista, mas continua no banco para relatório, suporte e recuperação.

Listagem: filtro, ordenação e paginação

A query de listagem é a mais visitada do produto inteiro. Ela precisa de três coisas desde o primeiro dia.

// projetos do usuário logado, mais recentes primeiro, 20 por página
const inicio = (pagina - 1) * 20;

const { data, error, count } = await supabase
  .from('projetos')
  .select('id, nome, status, criado_em', { count: 'exact' })
  .is('arquivado_em', null)          // esconde os arquivados
  .order('criado_em', { ascending: false })
  .range(inicio, inicio + 19);       // range é inclusivo nas duas pontas

Três detalhes que economizam retrabalho:

Liste as colunas em vez de usar select('*'). Cada coluna a mais é banda consumida por requisição, e banda é o item que primeiro estoura a conta no Supabase.

range() é inclusivo. Para 20 itens você pede de 0 a 19, não de 0 a 20. Errar isso gera aquele item repetido entre páginas que ninguém entende de onde veio.

Não filtre por user_id no front. A RLS faz isso no banco, e é lá que precisa ser feito. Filtro no cliente é conveniência, não segurança.

Criar e editar

// criar
const { data, error } = await supabase
  .from('projetos')
  .insert({ nome, status: 'rascunho' })
  .select()
  .single();

O .select().single() no fim faz o insert devolver a linha criada, com id e valores padrão já preenchidos pelo banco. Sem isso você fica sem o id e precisa de uma segunda query.

O user_id não vai no insert. Ele vem de um default auth.uid() na coluna, definido no banco:

alter table projetos
  alter column user_id set default auth.uid();

Assim o cliente não tem como mentir sobre a autoria do registro.

// editar
const { error } = await supabase
  .from('projetos')
  .update({ nome: novoNome })
  .eq('id', projetoId);

Um update sem .eq() atualiza a tabela inteira dentro do que a policy permitir. Trate .eq('id', id) como parte obrigatória da linha, não como opcional.

RLS: a diferença entre using e with check

Essa é a parte que mais gera confusão e a que mais importa. Cada operação precisa da sua policy.

-- ligar a proteção (sem isso, as policies não valem nada)
alter table projetos enable row level security;

-- leitura: só enxerga as próprias linhas
create policy "ler proprios projetos"
on projetos for select
using (auth.uid() = user_id);

-- criação: só grava linha com o próprio id
create policy "criar proprios projetos"
on projetos for insert
with check (auth.uid() = user_id);

-- edição: precisa das duas cláusulas
create policy "editar proprios projetos"
on projetos for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);

using decide quais linhas existentes a operação enxerga. with check decide se a linha resultante pode ser gravada. Um update com using mas sem with check permite que o usuário mude o user_id da própria linha e transfira o registro para outra conta.

Teste com dois usuários reais. Crie duas contas, faça login em navegadores diferentes e tente ler o registro da outra. Policy que nunca foi testada com dois usuários é policy não testada.

Erros do Postgres que você vai encontrar

O Supabase devolve o erro do Postgres direto. Vale reconhecer os três mais comuns.

Código Mensagem típica O que significa
42501 new row violates row-level security policy Falta policy para essa operação, ou o with check recusou
PGRST116 JSON object requested, multiple (or no) rows returned Você usou .single() e a query voltou zero ou mais de uma linha
23505 duplicate key value violates unique constraint Índice único bloqueou. Trate como aviso na interface, não como erro genérico

Quando o insert falha por RLS, o app não trava. Ele simplesmente não grava nada. Por isso todo error precisa aparecer na tela, nem que seja num toast.

Os três estados que quase ninguém implementa

A tela de lista tem quatro situações, e a maioria dos apps gerados por IA só trata uma.

Carregando: mostre esqueleto ou spinner. Sem isso a tela pisca vazia e o usuário acha que não tem nada.

Vazio: lista que voltou sem registro precisa de uma mensagem e de um botão de criar. Esse é o momento de maior chance de ativação do usuário novo.

Erro: mostre o que aconteceu e um botão de tentar de novo. Tela em branco com erro só no console é abandono garantido.

Com dados: o caso feliz.

if (carregando) return <Esqueleto />;
if (erro) return <Erro mensagem={erro.message} aoTentarNovamente={recarregar} />;
if (!dados?.length) return <Vazio aoCriar={abrirModal} />;
return <Lista itens={dados} />;

Rota protegida sem flash de conteúdo

Rota protegida mal feita mostra a tela por meio segundo antes de redirecionar. Isso acontece quando o componente renderiza antes de a sessão ser verificada.

const [sessao, setSessao] = useState(undefined); // undefined = ainda checando

useEffect(() => {
  supabase.auth.getSession().then(({ data }) => setSessao(data.session));

  const { data: sub } = supabase.auth.onAuthStateChange((_evento, s) => {
    setSessao(s);
  });

  return () => sub.subscription.unsubscribe();
}, []);

if (sessao === undefined) return <Carregando />;  // estado intermediário
if (sessao === null) return <Redirecionar para="/login" />;
return children;

O truque é ter três estados em vez de dois: verificando, sem sessão, com sessão. Se você inicializar como null, o app assume que não tem login e redireciona antes de conferir.

O onAuthStateChange cobre o caso do token que expira com o usuário na tela e o do logout feito em outra aba.

Erros comuns nessa etapa

Erro Consequência Correção
Confiar no filtro do front para separar dados Vazamento entre contas RLS ligada em toda tabela com dado de usuário
select('*') em toda listagem Banda e payload maiores sem necessidade Listar colunas usadas na tela
Ignorar o objeto error Botão que não faz nada Toast ou mensagem em toda operação
Delete real em vez de arquivamento Histórico perdido Coluna arquivado_em e filtro .is(..., null)
Policy escrita e nunca testada Falsa sensação de segurança Teste com duas contas em navegadores diferentes

O que vem na parte 4

Com CRUD funcionando e acesso protegido, falta a parte que transforma projeto em produto: cobrar. Na parte 4 a série entra em assinatura, liberação de acesso por plano e o que fazer quando o pagamento falha.

Se você está construindo junto, o ponto de parada desta etapa é claro: duas contas de teste, cada uma enxergando só os próprios registros, com criar, editar e arquivar funcionando e mensagem de erro visível em cada um deles. Quem quiser acompanhar montando no mesmo ambiente, o Lovable já vem com a integração do Supabase pronta.

Tags
#saas#supabase#crud#rls#jornada saas#vibe coding
● Não perca essa chance

Não perca a próxima edição.

Toda quinta, 9h. Direto na sua caixa.

  • Ferramentas que economizam horas do seu trabalho
  • Agentes e automações que funcionam
  • Bastidores do que estamos construindo

100% gratuito. Cancele quando quiser.

Compartilhar