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.
Sumário do artigo
- As quatro operações e onde cada uma quebra
- Listagem: filtro, ordenação e paginação
- Criar e editar
- RLS: a diferença entre using e with check
- Erros do Postgres que você vai encontrar
- Os três estados que quase ninguém implementa
- Rota protegida sem flash de conteúdo
- Erros comuns nessa etapa
- O que vem na parte 4

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