Supabase do zero ao nível expert
Uma apostila completa e orientada ao mercado de trabalho: você aprende o backend inteiro — banco de dados, autenticação, arquivos, tempo real e funções serverless — construindo sobre PostgreSQL, a habilidade mais pedida em vagas de backend e full-stack.
O que é o Supabase (e por que empresas o usam)
- Explicar o que é um BaaS e onde o Supabase se encaixa numa arquitetura;
- Comparar Supabase com Firebase e com um backend tradicional;
- Nomear os 6 produtos da plataforma e o papel de cada um.
1.1 O problema que o Supabase resolve
Toda aplicação séria precisa das mesmas peças: um banco de dados, um sistema de login, um lugar para guardar arquivos, uma forma de reagir a eventos em tempo real e um lugar para rodar código no servidor. Montar isso do zero exige semanas de configuração — servidor, ORM, JWT, refresh tokens, buckets, websockets, deploy…
O Supabase é um BaaS (Backend as a Service): ele entrega todas essas peças prontas, integradas e gerenciadas, em cima de um banco PostgreSQL real e completo. Você cria um projeto e, em minutos, tem uma API REST gerada automaticamente a partir das suas tabelas, autenticação pronta e SDKs para JavaScript, Flutter, Python e outras linguagens.
1.2 Os 6 produtos da plataforma
| Produto | O que faz | Tecnologia por trás |
|---|---|---|
| Database | Banco relacional completo, com SQL, extensões, triggers e funções | PostgreSQL |
| Auth | Login com e-mail/senha, OAuth (Google, GitHub…), magic link, OTP, MFA | GoTrue + JWT |
| Storage | Upload e entrega de arquivos e imagens, com permissões | Compatível com S3 |
| Realtime | Escutar mudanças no banco, canais de broadcast e presença | Elixir/Phoenix + WebSockets |
| Edge Functions | Código serverless em TypeScript rodando perto do usuário | Deno |
| Vector / IA | Busca semântica e armazenamento de embeddings para apps de IA | pgvector |
1.3 Supabase vs. Firebase vs. backend próprio
Essa comparação cai direto em entrevistas. O ponto central: o Firebase usa um banco de documentos NoSQL proprietário; o Supabase usa PostgreSQL — aberto, relacional e padrão de mercado.
| Supabase | Firebase | Backend próprio | |
|---|---|---|---|
| Banco | PostgreSQL (relacional, SQL) | Firestore (NoSQL, proprietário) | Você escolhe |
| Consultas complexas (joins, agregações) | Nativas em SQL | Limitadas, exigem modelagem criativa | Depende de você |
| Lock-in | Baixo: é Postgres, dá para migrar ou auto-hospedar (open source) | Alto | Nenhum |
| Velocidade para MVP | Altíssima | Altíssima | Baixa |
| Controle fino | Alto (SQL, RLS, extensões) | Médio | Total |
PostgreSQL aparece há anos entre os bancos mais usados e mais amados nas pesquisas anuais da Stack Overflow. Ao aprender Supabase você está, na prática, aprendendo Postgres + SQL + modelagem + segurança de dados — habilidades transferíveis para qualquer vaga de backend, mesmo em empresas que não usam Supabase. É esse o argumento que você deve usar no currículo e na entrevista: não "sei uma ferramenta", e sim "domino Postgres e sei entregar produto rápido com ele".
Quem contrata Supabase hoje: startups em estágio inicial, agências que entregam MVPs, produtos indie/SaaS e times pequenos de produto dentro de empresas maiores.
1.4 Quando NÃO usar Supabase
- Workloads que não são bem servidos por um banco relacional central (ex.: grafos gigantes, séries temporais massivas) — embora extensões ajudem em muitos casos;
- Empresas com exigência de infraestrutura 100% interna sem equipe para auto-hospedar;
- Lógica de negócio extremamente pesada que pediria um backend dedicado — nesse caso, o Supabase ainda pode ser "só o banco + auth", com sua API própria ao lado.
Saber dizer quando não usar uma ferramenta é um dos sinais mais fortes de senioridade em entrevistas.
- Escreva, com suas palavras, um parágrafo explicando Supabase para um gerente não técnico.
- Liste 3 tipos de aplicação em que o modelo relacional do Postgres vence o Firestore, e justifique.
- Pesquise 3 vagas reais (LinkedIn/Gupy) que citem Supabase ou PostgreSQL e anote os requisitos que se repetem — essa lista vai guiar seu estudo.
Primeiros passos: projeto, dashboard e primeiro código
- Criar um projeto e navegar pelo dashboard com segurança;
- Entender as chaves de API (anon/publishable vs. service_role/secret) e o risco de cada uma;
- Conectar um app JavaScript ao seu projeto.
2.1 Criando o projeto
- Acesse
supabase.come crie uma conta (login com GitHub é o caminho natural para devs). - Crie uma Organization e depois um Project: escolha nome, uma senha forte para o banco (guarde-a!) e a região mais próxima dos seus usuários (ex.: São Paulo /
sa-east-1para o Brasil). - Aguarde ~2 minutos: o Supabase provisiona um Postgres dedicado para você.
2.2 Tour pelo dashboard
- Table Editor — criar e editar tabelas visualmente, como uma planilha;
- SQL Editor — rodar SQL direto no banco (será sua casa a partir do módulo 3);
- Authentication — usuários, provedores de login, templates de e-mail;
- Storage — buckets de arquivos;
- Edge Functions — funções serverless;
- Database — roles, extensões, replicação, backups;
- Logs & Reports — o que está acontecendo no projeto;
- Settings → API — URL do projeto e chaves.
2.3 As chaves de API — leia com atenção
Em Settings → API você encontra:
| Chave | Onde usar | Poder |
|---|---|---|
anon / publishable | Frontend (navegador, app mobile). Pode ficar exposta. | Só consegue o que as políticas de RLS permitirem |
service_role / secret | Somente servidor (Edge Functions, backend próprio, scripts) | Ignora RLS: acesso total ao banco |
Nunca coloque a chave service_role no frontend, em repositório público ou em variável NEXT_PUBLIC_* / VITE_*. Quem tem essa chave lê e apaga qualquer dado do seu banco. A chave anon pode ser pública somente porque o RLS (módulo 6) limita o que ela pode fazer — sem RLS ativado, a anon também é uma porta aberta.
2.4 Conectando via JavaScript
bash# em um projeto Node/Vite/Next.js npm install @supabase/supabase-js
javascript// src/lib/supabase.js — crie o client UMA vez e reutilize import { createClient } from '@supabase/supabase-js' const supabaseUrl = import.meta.env.VITE_SUPABASE_URL const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY export const supabase = createClient(supabaseUrl, supabaseKey)
bash# .env (nunca commite este arquivo — adicione ao .gitignore) VITE_SUPABASE_URL=https://xxxx.supabase.co VITE_SUPABASE_ANON_KEY=eyJhbGciOi...
2.5 Ambiente local com a CLI (opcional agora, essencial depois)
Profissionalmente, você não desenvolve direto no projeto de produção. A CLI do Supabase sobe toda a stack localmente via Docker:
bash# instalar a CLI (macOS/Linux via brew; Windows via scoop ou npx) npm install supabase --save-dev npx supabase init # cria a pasta supabase/ no repositório npx supabase start # sobe Postgres + Auth + Storage + Studio local npx supabase status # mostra URLs e chaves locais
Voltaremos à CLI no módulo 10 (migrations) e no módulo 11 (CI/CD).
Fluxo profissional que você deve citar em entrevista: banco local via CLI → migrations versionadas no Git → deploy para staging → produção. Times que usam Supabase valorizam quem já chega sabendo esse fluxo, porque a maioria dos iniciantes edita produção direto pelo dashboard — e isso quebra ambientes.
- Crie um projeto chamado
apostila-labna região de São Paulo. - Crie um app Vite (
npm create vite@latest), instale o supabase-js e monte o arquivosupabase.jscom variáveis de ambiente. - Explique em 3 linhas por que a chave anon pode ir ao navegador e a service_role não.
PostgreSQL essencial: modelando seus dados
- Criar tabelas com tipos, constraints e valores padrão adequados;
- Modelar relações 1-N e N-N com chaves estrangeiras;
- Escrever SELECTs com filtros, joins, agregações e ordenação.
3.1 O projeto-guia da apostila
Dos módulos 3 a 9 construiremos o backend de um app real de portfólio: o DevBoard — um gerenciador de tarefas colaborativo (estilo Trello simplificado) com usuários, quadros, tarefas, comentários e anexos. Ele exige exatamente as habilidades que o mercado pede: modelagem relacional, permissões por usuário, arquivos e tempo real.
3.2 Criando tabelas com SQL
Você pode usar o Table Editor visual, mas profissionais escrevem SQL — é versionável, repetível e cai em entrevista. No SQL Editor:
sql-- Quadros (boards) pertencem a um usuário do Auth create table boards ( id uuid primary key default gen_random_uuid(), owner_id uuid not null references auth.users(id) on delete cascade, title text not null check (char_length(title) between 1 and 80), created_at timestamptz not null default now() ); -- Tarefas pertencem a um quadro (relação 1-N) create table tasks ( id bigint generated always as identity primary key, board_id uuid not null references boards(id) on delete cascade, title text not null, status text not null default 'todo' check (status in ('todo', 'doing', 'done')), due_date date, created_at timestamptz not null default now() );
Tipos que você usará o tempo todo
| Tipo | Uso |
|---|---|
uuid | IDs públicos e não sequenciais (padrão para chaves ligadas a usuários) |
bigint generated always as identity | IDs numéricos autoincrementais internos |
text | Qualquer texto (no Postgres, prefira a varchar(n) + use check para limites) |
timestamptz | Data e hora com fuso — sempre prefira ao timestamp puro |
numeric(10,2) | Dinheiro (nunca use float para valores monetários!) |
boolean, date, jsonb | Flags, datas puras e documentos JSON indexáveis |
3.3 Relação N-N: tabela de junção
Um quadro pode ter vários membros; um usuário participa de vários quadros. Isso pede uma tabela intermediária:
sqlcreate table board_members ( board_id uuid references boards(id) on delete cascade, user_id uuid references auth.users(id) on delete cascade, role text not null default 'member' check (role in ('member','admin')), primary key (board_id, user_id) -- chave composta impede duplicatas );
3.4 Consultando: o SELECT que cai em toda entrevista
sql-- Filtro, ordenação e limite select id, title, status from tasks where status = 'doing' and due_date <= current_date + 7 order by due_date asc limit 20; -- JOIN: tarefas com o título do quadro select t.title as tarefa, b.title as quadro from tasks t join boards b on b.id = t.board_id; -- Agregação: quantas tarefas por status em cada quadro select b.title, t.status, count(*) as total from tasks t join boards b on b.id = t.board_id group by b.title, t.status order by b.title;
3.5 Alterando e apagando com segurança
sqlupdate tasks set status = 'done' where id = 42; -- Regra de ouro: TODO update/delete tem WHERE. -- Dica: rode antes um SELECT com o mesmo WHERE para conferir o alvo. delete from tasks where status = 'done' and created_at < now() - interval '90 days';
Entrevistas de backend quase sempre têm uma etapa de SQL ao vivo: joins, group by e uma pergunta sobre chaves estrangeiras ou normalização. Pratique reescrevendo as consultas deste módulo sem olhar. Saber justificar por que usou uuid vs identity, ou on delete cascade vs restrict, diferencia você de quem só decorou sintaxe.
- Crie a tabela
comments(id, task_id → tasks, author_id → auth.users, content, created_at) com as constraints adequadas. - Escreva um SELECT que traga os 5 quadros com mais tarefas pendentes (
status != 'done'). - Explique a diferença entre
on delete cascade,set nullerestrict— e escolha o mais adequado paracomments.author_id.
CRUD com supabase-js: a API automática
- Fazer select, insert, update, delete e upsert pelo client JS;
- Usar filtros, ordenação, paginação e joins aninhados;
- Chamar funções do banco via RPC e tratar erros corretamente.
4.1 Como a API nasce das suas tabelas
O Supabase roda o PostgREST, um servidor que transforma o schema do Postgres numa API REST automaticamente. Criou a tabela tasks? Existe um endpoint /rest/v1/tasks. O supabase-js é um client elegante em cima dessa API — cada método vira parâmetros de URL por baixo dos panos.
4.2 Leitura (select)
javascript// Todas as colunas const { data, error } = await supabase.from('tasks').select('*') // Colunas específicas + filtro + ordenação + paginação const { data, error } = await supabase .from('tasks') .select('id, title, status, due_date') .eq('status', 'doing') .order('due_date', { ascending: true }) .range(0, 19) // paginação: itens 0–19
Filtros mais usados
| Método | SQL equivalente |
|---|---|
.eq('status','done') / .neq() | = / != |
.gt() .gte() .lt() .lte() | > >= < <= |
.like('title','%api%') / .ilike() | LIKE / ILIKE (i = ignora maiúsculas) |
.in('status',['todo','doing']) | IN (...) |
.is('due_date', null) | IS NULL |
.or('status.eq.done,due_date.lt.2026-01-01') | OR |
4.3 Joins aninhados: o superpoder do select
Se existe chave estrangeira, o PostgREST monta o join para você — basta aninhar no select:
javascript// Quadros com suas tarefas e, dentro delas, os comentários const { data } = await supabase .from('boards') .select(` id, title, tasks ( id, title, status, comments ( id, content ) ) `) .eq('tasks.status', 'doing') // filtro na tabela aninhada
4.4 Escrita: insert, update, upsert, delete
javascript// INSERT — .select() ao final retorna a linha criada const { data, error } = await supabase .from('tasks') .insert({ board_id, title: 'Estudar RLS' }) .select() .single() // espera exatamente 1 linha; erro se 0 ou 2+ // UPDATE — SEMPRE com filtro await supabase.from('tasks') .update({ status: 'done' }) .eq('id', taskId) // UPSERT — insere ou atualiza se a chave já existir await supabase.from('board_members') .upsert({ board_id, user_id, role: 'admin' }) // DELETE await supabase.from('tasks').delete().eq('id', taskId)
4.5 Tratamento de erros — o jeito profissional
O supabase-js não lança exceção em erros de API: ele retorna { data, error }. Ignorar o error é o bug nº 1 de iniciantes.
javascriptasync function createTask(boardId, title) { const { data, error } = await supabase .from('tasks') .insert({ board_id: boardId, title }) .select() .single() if (error) { // error.code traz o código Postgres: '23505' = violação de unique, etc. console.error('Falha ao criar tarefa:', error.message) throw new Error('Não foi possível criar a tarefa.') } return data }
4.6 RPC: chamando funções do banco
Lógica que envolve várias tabelas ou precisa ser atômica deve virar uma função Postgres (módulo 10) chamada via rpc:
javascriptconst { data, error } = await supabase .rpc('mover_tarefa', { task_id: 42, novo_status: 'done' })
Gere tipos do seu schema com npx supabase gen types typescript --project-id XXX > src/database.types.ts e passe-os ao client: createClient<Database>(...). Autocomplete de tabelas e colunas no editor — e um item forte para citar em entrevista.
Times avaliam se você sabe paginar (nunca carregar tabelas inteiras), tratar erros e tipar o acesso a dados. Um repositório de portfólio com essas três práticas já se destaca da maioria.
- Implemente
listarTarefasAtrasadas(boardId): status ≠ done, due_date < hoje, ordenado por vencimento, com paginação de 10. - Implemente busca por texto no título usando
ilike. - Escreva um select aninhado que devolva um quadro com membros (via
board_members) e tarefas num único request.
Autenticação: usuários, sessões e OAuth
- Implementar cadastro, login, logout e recuperação de senha;
- Adicionar login social (Google/GitHub) e magic link;
- Entender JWT e sessões, e reagir a mudanças de auth no frontend;
- Criar a tabela
profilesligada ao usuário — padrão universal.
5.1 Como funciona por baixo
O Supabase Auth emite um JWT (JSON Web Token) quando o usuário loga. Esse token vai em cada request e carrega o user_id (claim sub) e o papel (role). É esse token que o RLS usará no módulo 6 para decidir o que cada usuário pode ver. O client renova a sessão automaticamente com um refresh token.
5.2 E-mail e senha
javascript// Cadastro const { data, error } = await supabase.auth.signUp({ email: 'ana@exemplo.com', password: 'senha-forte-123', options: { data: { display_name: 'Ana' } } // metadados do usuário }) // Login const { data, error } = await supabase.auth.signInWithPassword({ email: 'ana@exemplo.com', password: 'senha-forte-123' }) // Logout await supabase.auth.signOut() // Usuário atual (validado no servidor — prefira a getSession p/ decisões de segurança) const { data: { user } } = await supabase.auth.getUser()
Por padrão, o Supabase exige confirmação de e-mail (configurável em Authentication → Providers). Os templates dos e-mails são personalizáveis — traduza-os para português antes de mostrar o projeto a alguém.
5.3 Login social (OAuth) e magic link
javascript// OAuth — requer configurar o provedor no dashboard (client id/secret) await supabase.auth.signInWithOAuth({ provider: 'github', options: { redirectTo: 'https://meuapp.com/callback' } }) // Magic link — login sem senha, por e-mail await supabase.auth.signInWithOtp({ email: 'ana@exemplo.com' })
5.4 Reagindo ao estado de auth (React)
javascriptimport { useEffect, useState } from 'react' import { supabase } from './lib/supabase' export function useSession() { const [session, setSession] = useState(null) useEffect(() => { supabase.auth.getSession().then(({ data }) => setSession(data.session)) const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => setSession(session)) return () => subscription.unsubscribe() }, []) return session }
5.5 O padrão profiles — memorize este bloco
A tabela auth.users pertence ao sistema; você não deve pendurar colunas nela. O padrão é uma tabela profiles no schema público, criada automaticamente por trigger a cada cadastro:
sqlcreate table profiles ( id uuid primary key references auth.users(id) on delete cascade, display_name text, avatar_url text, created_at timestamptz default now() ); -- Trigger: cria o profile junto com o usuário create function handle_new_user() returns trigger language plpgsql security definer set search_path = '' as $$ begin insert into public.profiles (id, display_name) values (new.id, new.raw_user_meta_data->>'display_name'); return new; end; $$; create trigger on_auth_user_created after insert on auth.users for each row execute function handle_new_user();
Em código de servidor, use supabase.auth.getUser() (que valida o token junto ao servidor de Auth) para decisões de autorização — getSession() lê o token do cookie/armazenamento sem revalidar. Para Next.js, use o pacote @supabase/ssr, que gerencia a sessão via cookies corretamente entre server e client.
Perguntas frequentes de entrevista que este módulo responde: "o que é um JWT e o que ele carrega?", "diferença entre autenticação e autorização?" (autenticação = quem é você → este módulo; autorização = o que você pode → módulo 6), "como funciona refresh token?". Implementar MFA/TOTP no seu projeto de portfólio é um diferencial raro entre juniores.
- Monte telas de cadastro/login/logout no seu app Vite usando as funções acima.
- Configure login com GitHub (crie um OAuth App no GitHub e cole as credenciais no dashboard).
- Implemente o padrão
profiles+ trigger e confirme que um novo cadastro cria a linha automaticamente.
Row Level Security: o coração da segurança
- Explicar o que é RLS e por que ele permite expor o banco ao frontend;
- Escrever políticas de select/insert/update/delete com
auth.uid(); - Aplicar os padrões: dados públicos, dados do dono, dados compartilhados por equipe;
- Evitar as armadilhas clássicas (recursão, performance, políticas permissivas demais).
6.1 O conceito
Row Level Security é um recurso do próprio Postgres: regras SQL que decidem, linha por linha, quem pode ler ou escrever. Como a regra vive no banco, ela vale para qualquer caminho de acesso — API REST, realtime, SQL direto. É isso que torna seguro usar a chave anon no navegador.
Modelo mental: "toda consulta ganha um WHERE invisível definido por você".
6.2 Ativando e criando a primeira política
sql-- 1. Ativar RLS na tabela (a partir daí, TUDO é negado por padrão) alter table boards enable row level security; -- 2. Permitir que o dono veja seus quadros create policy "donos veem seus quadros" on boards for select to authenticated using ( owner_id = (select auth.uid()) );
Anatomia de uma política:
for select|insert|update|delete|all— a operação coberta;to authenticated|anon— a quem se aplica (logados ou anônimos);using (…)— condição para linhas existentes (leitura/alteração/remoção);with check (…)— condição para linhas novas ou alteradas (insert/update);auth.uid()— função do Supabase que devolve o id do usuário logado, extraído do JWT.
6.3 O conjunto completo para "cada um no seu quadrado"
sqlcreate policy "criar quadros próprios" on boards for insert to authenticated with check ( owner_id = (select auth.uid()) ); create policy "editar quadros próprios" on boards for update to authenticated using ( owner_id = (select auth.uid()) ) with check ( owner_id = (select auth.uid()) ); create policy "apagar quadros próprios" on boards for delete to authenticated using ( owner_id = (select auth.uid()) );
6.4 Padrão: acesso por equipe (membership)
Tarefas devem ser visíveis a membros do quadro, não só ao dono. A condição consulta a tabela de junção:
sqlalter table tasks enable row level security; create policy "membros veem tarefas do quadro" on tasks for select to authenticated using ( exists ( select 1 from board_members bm where bm.board_id = tasks.board_id and bm.user_id = (select auth.uid()) ) );
Se a política de board_members consultar board_members, o Postgres entra em recursão e retorna erro. A solução profissional é mover a checagem para uma função security definer (que roda ignorando RLS) e chamá-la na política:
sqlcreate function is_board_member(b_id uuid) returns boolean language sql security definer set search_path = '' as $$ select exists ( select 1 from public.board_members where board_id = b_id and user_id = (select auth.uid()) ); $$;
6.5 Padrão: conteúdo público para leitura
sql-- perfis são públicos para leitura, editáveis só pelo dono create policy "perfis são públicos" on profiles for select to anon, authenticated using ( true ); create policy "cada um edita o seu" on profiles for update to authenticated using ( id = (select auth.uid()) );
6.6 Performance de RLS
- Envolva
auth.uid()em(select auth.uid())— permite ao Postgres calcular uma vez por consulta, não por linha; - Crie índice nas colunas usadas pelas políticas:
create index on tasks (board_id);ecreate index on board_members (user_id, board_id); - Prefira
existsain (select …)em condições de membership; - Declare
to authenticated/to anonsempre — evita avaliar políticas para papéis que nem deveriam tentar.
RLS é o tema que separa quem "já mexeu com Supabase" de quem sabe usá-lo em produção. Em entrevistas para vagas com Supabase, espere: "como você faria multi-tenancy?", "o que acontece se esquecer de ativar RLS numa tabela?" (resposta: com a anon key e a tabela exposta, qualquer pessoa lê tudo), "quando usar security definer?". Domine este módulo e você estará à frente da maioria dos candidatos.
- Escreva as políticas completas de
tasks(insert/update/delete para membros; delete talvez só para admins do quadro). - Teste com dois usuários diferentes: confirme que o usuário B não vê os quadros do usuário A (abra dois navegadores).
- Provoque de propósito a recursão em
board_memberse conserte comis_board_member().
Storage: arquivos, imagens e permissões
- Criar buckets públicos e privados e entender a diferença;
- Fazer upload, download e listar arquivos pelo client;
- Gerar URLs públicas e URLs assinadas (temporárias);
- Proteger arquivos com políticas de RLS no Storage.
7.1 Buckets: público vs. privado
- Bucket público: qualquer pessoa com a URL lê o arquivo. Ideal para avatares, imagens de produto, assets do site.
- Bucket privado: leitura só via política de RLS ou URL assinada com validade. Ideal para documentos, anexos, comprovantes.
Crie pelo dashboard (Storage → New bucket) ou por SQL. Convenção que usaremos: avatars (público) e attachments (privado).
7.2 Upload e leitura
javascript// Upload — caminho organizado por usuário (importante p/ as políticas!) const file = inputEl.files[0] const path = `${user.id}/${crypto.randomUUID()}-${file.name}` const { error } = await supabase.storage .from('avatars') .upload(path, file, { upsert: true, contentType: file.type }) // URL pública (bucket público) const { data } = supabase.storage.from('avatars').getPublicUrl(path) // data.publicUrl → salve em profiles.avatar_url // URL assinada (bucket privado) — expira em 1 hora const { data: signed } = await supabase.storage .from('attachments') .createSignedUrl(path, 3600) // Download direto (respeita RLS) const { data: blob } = await supabase.storage .from('attachments') .download(path)
7.3 Políticas de Storage
Arquivos são linhas na tabela storage.objects — logo, RLS funciona igual. O truque de mercado: usar a primeira pasta do caminho como id do dono.
sql-- Cada usuário só grava dentro da própria pasta: avatars/{seu-uuid}/... create policy "upload na própria pasta" on storage.objects for insert to authenticated with check ( bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid()::text) ); -- Anexos: só membros do quadro leem (reusando a função do módulo 6) create policy "membros leem anexos do quadro" on storage.objects for select to authenticated using ( bucket_id = 'attachments' and is_board_member( ((storage.foldername(name))[1])::uuid ) );
7.4 Transformação de imagens e uploads grandes
- Em planos pagos, o Supabase redimensiona imagens on-the-fly:
getPublicUrl(path, { transform: { width: 200, height: 200 } })— perfeito para thumbnails; - Para arquivos grandes, use upload resumável (TUS) com a lib
tus-js-client— retoma de onde parou se a conexão cair; - Limite tamanho e tipos de arquivo nas configurações do bucket (ex.: 5 MB, apenas
image/*) — validação no cliente é cortesia, no bucket é segurança.
Upload de arquivos é tarefa de sprint 1 em quase todo produto. Saber responder "como você impediria um usuário de baixar o contrato de outro?" (bucket privado + política por pasta + URL assinada curta) mostra maturidade. Cite também validação de MIME type e limite de tamanho no servidor — segurança nunca só no front.
- Implemente upload de avatar com preview e atualização de
profiles.avatar_url. - Crie o bucket privado
attachmentscom upload de anexos por tarefa, organizados porboard_id/. - Gere uma URL assinada de 60 segundos e comprove no navegador que ela expira.
Realtime: dados vivos na tela
- Escutar INSERT/UPDATE/DELETE de tabelas (Postgres Changes);
- Usar Broadcast para mensagens efêmeras entre clientes;
- Usar Presence para saber quem está online;
- Escolher a ferramenta certa para cada caso.
8.1 Os três modos
| Modo | O que é | Casos de uso |
|---|---|---|
| Postgres Changes | Eventos quando linhas mudam no banco | Lista de tarefas que atualiza sozinha, notificações |
| Broadcast | Mensagens cliente↔cliente que não passam pelo banco | Cursor do colega na tela, "fulano está digitando…" |
| Presence | Estado sincronizado de quem está no canal | Bolinhas de "online agora", contagem de espectadores |
8.2 Postgres Changes na prática
Primeiro, habilite a publicação da tabela (Database → Publications, ou SQL):
sqlalter publication supabase_realtime add table tasks;
javascriptconst channel = supabase .channel('tasks-do-quadro') .on('postgres_changes', { event: '*', // ou 'INSERT' | 'UPDATE' | 'DELETE' schema: 'public', table: 'tasks', filter: `board_id=eq.${boardId}` }, (payload) => { console.log(payload.eventType, payload.new, payload.old) // atualize seu estado React/Vue aqui } ) .subscribe() // SEMPRE limpe ao desmontar o componente: supabase.removeChannel(channel)
O RLS também vale aqui: o usuário só recebe eventos de linhas que suas políticas de select permitem ver.
8.3 Broadcast e Presence
javascriptconst room = supabase.channel(`board:${boardId}`, { config: { presence: { key: user.id } } }) // Broadcast: enviar e receber room.on('broadcast', { event: 'typing' }, ({ payload }) => { mostrarDigitando(payload.name) }) room.send({ type: 'broadcast', event: 'typing', payload: { name: 'Ana' } }) // Presence: quem está online room.on('presence', { event: 'sync' }, () => { const online = room.presenceState() // { userId: [meta...] } }) room.subscribe(async (status) => { if (status === 'SUBSCRIBED') await room.track({ name: 'Ana' }) })
1) Broadcast escala melhor que Postgres Changes para alto volume — para feeds intensos, um padrão comum é um trigger no banco enviando broadcast. 2) Reconcilie estado: ao reconectar, refaça um select para não perder eventos ocorridos offline. 3) Um canal por contexto (por quadro), não um canal global.
"Colaborativo em tempo real" é feature de destaque em produto — e a maioria dos devs nunca implementou. Um DevBoard onde duas janelas se atualizam simultaneamente é uma demo curta e devastadoramente eficaz em entrevista. Grave um GIF disso para o README do repositório.
- Faça a lista de tarefas atualizar em tempo real entre duas janelas de navegador.
- Adicione o indicador "X está digitando…" nos comentários usando broadcast com debounce.
- Mostre avatares dos membros online no topo do quadro usando presence.
Edge Functions: seu código no servidor
- Criar, testar localmente e fazer deploy de Edge Functions;
- Usar secrets e o client admin (service_role) com segurança;
- Receber webhooks (ex.: Stripe) e reagir a eventos do banco;
- Decidir o que vai para Edge Function vs. função do banco.
9.1 Quando o frontend não basta
Tudo que envolve segredos (chaves de APIs de terceiros), validação que o usuário não pode burlar ou integrações (pagamento, e-mail, IA) precisa rodar no servidor. Edge Functions são funções TypeScript no runtime Deno, distribuídas globalmente, disparadas por HTTP.
9.2 Criando e rodando localmente
bashnpx supabase functions new enviar-boas-vindas # cria supabase/functions/enviar-boas-vindas/index.ts npx supabase functions serve # roda local com hot reload npx supabase functions deploy enviar-boas-vindas npx supabase secrets set RESEND_API_KEY=re_xxx
typescript// supabase/functions/enviar-boas-vindas/index.ts Deno.serve(async (req) => { const { nome, email } = await req.json() const resp = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from: 'DevBoard <oi@devboard.app>', to: email, subject: `Bem-vindo(a), ${nome}!`, html: '<p>Sua conta está pronta 🎉</p>' }) }) return new Response(JSON.stringify({ ok: resp.ok }), { headers: { 'Content-Type': 'application/json' } }) })
9.3 Identificando o usuário e usando o client admin
typescriptimport { createClient } from 'jsr:@supabase/supabase-js@2' Deno.serve(async (req) => { // 1) Client com o token do usuário → RLS é respeitado const userClient = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, { global: { headers: { Authorization: req.headers.get('Authorization')! } } } ) const { data: { user } } = await userClient.auth.getUser() if (!user) return new Response('Não autorizado', { status: 401 }) // 2) Client admin → ignora RLS. Use SOMENTE após validar permissões você mesmo. const admin = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ) // ... lógica privilegiada aqui ... return new Response('ok') })
9.4 Webhooks: o exemplo Stripe
Padrão de mercado para SaaS: o Stripe chama sua função quando um pagamento acontece; você atualiza o plano do usuário no banco.
typescriptimport Stripe from 'npm:stripe' const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!) Deno.serve(async (req) => { const sig = req.headers.get('stripe-signature')! const body = await req.text() // Verificar a assinatura é OBRIGATÓRIO — sem isso, qualquer um forja eventos const event = await stripe.webhooks.constructEventAsync( body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET')! ) if (event.type === 'checkout.session.completed') { // admin.from('subscriptions').upsert(...) } return new Response('ok') })
Deploy com --no-verify-jwt nesse caso específico, pois o Stripe não envia JWT do Supabase — a segurança vem da assinatura do webhook.
9.5 Database Webhooks: o banco chamando funções
Em Database → Webhooks, configure "quando inserir em comments, chame a função notificar-mencao". É o cimento entre eventos do banco e efeitos externos (e-mail, push, Slack).
Edge Function vs. função Postgres: quem faz o quê?
| Use função do banco (módulo 10) | Use Edge Function |
|---|---|
| Lógica sobre dados: transações, agregações, validações | Chamar APIs externas (pagamento, e-mail, IA) |
| Precisa ser atômica com o dado | Precisa de segredos de terceiros |
| Baixíssima latência junto ao dado | Processamento fora do banco (gerar PDF, redimensionar) |
Integração com pagamento (Stripe/Mercado Pago) via webhook é possivelmente a habilidade mais monetizável deste curso — freelas de "adicionar assinatura ao meu SaaS" pagam bem e este módulo é exatamente esse serviço. Em entrevista, saiba explicar por que a verificação de assinatura do webhook é inegociável.
- Crie uma função
resumo-do-quadroque retorna contagens de tarefas por status, autenticando o usuário e respeitando RLS. - Configure um Database Webhook: novo comentário → função que registra num log (comece com
console.log). - (Desafio) Integre o modo de testes do Stripe com checkout e webhook de confirmação.
Postgres avançado: o que separa júnior de pleno
- Criar índices certos e ler um
EXPLAIN ANALYZE; - Escrever funções PL/pgSQL, triggers e views;
- Implementar busca full-text em português e tarefas agendadas;
- Versionar o banco com migrations pela CLI;
- Entender connection pooling (Supavisor) e quando ele importa.
10.1 Índices e performance
Sem índice, o Postgres lê a tabela inteira (Seq Scan). Regra prática: indexe colunas de WHERE, JOIN e ORDER BY frequentes — inclusive toda chave estrangeira (o Postgres não as indexa automaticamente!).
sqlcreate index idx_tasks_board on tasks (board_id); create index idx_tasks_status_due on tasks (status, due_date); -- composto create index idx_tasks_pendentes on tasks (due_date) where status != 'done'; -- índice parcial: menor e mais rápido -- Medindo: rode antes e depois de criar o índice explain analyze select * from tasks where board_id = '...' and status = 'doing';
Na saída, procure: Seq Scan (ruim em tabela grande) vs. Index Scan (bom), e o tempo real de execução. O dashboard também mostra consultas lentas em Database → Query Performance — hábito de quem cuida de produção.
10.2 Funções PL/pgSQL e transações
sql-- Regra de negócio atômica: mover tarefa registrando histórico create table task_history ( id bigint generated always as identity primary key, task_id bigint references tasks(id) on delete cascade, old_status text, new_status text, changed_by uuid, changed_at timestamptz default now() ); create function mover_tarefa(task_id bigint, novo_status text) returns tasks language plpgsql security invoker as $$ declare t tasks; begin update tasks set status = novo_status where id = task_id returning * into t; if not found then raise exception 'Tarefa % não encontrada', task_id; end if; insert into task_history (task_id, new_status, changed_by) values (task_id, novo_status, auth.uid()); return t; -- tudo na mesma transação: ou grava os dois, ou nenhum end; $$;
security invoker = roda com as permissões de quem chamou (RLS vale). security definer = roda com as permissões do criador (ignora RLS) — use com parcimônia e sempre com set search_path = ''.
10.3 Triggers para automatizar
sql-- updated_at automático — todo projeto profissional tem alter table tasks add column updated_at timestamptz default now(); create function set_updated_at() returns trigger language plpgsql as $$ begin new.updated_at = now(); return new; end; $$; create trigger trg_tasks_updated before update on tasks for each row execute function set_updated_at();
10.4 Views e busca full-text em português
sql-- View: consulta complexa vira "tabela virtual" consumível pela API create view board_stats with (security_invoker = true) as select b.id, b.title, count(t.id) filter (where t.status != 'done') as pendentes, count(t.id) as total from boards b left join tasks t on t.board_id = b.id group by b.id; -- Full-text search com dicionário português + índice GIN alter table tasks add column fts tsvector generated always as (to_tsvector('portuguese', title)) stored; create index idx_tasks_fts on tasks using gin (fts);
javascript// no client: await supabase.from('tasks') .select().textSearch('fts', 'relatório & urgente', { config: 'portuguese' })
10.5 Extensões e tarefas agendadas
O Postgres é extensível — e o Supabase traz dezenas de extensões prontas (Database → Extensions):
pg_cron— agendar SQL: limpar dados antigos toda noite, fechar relatórios;pg_net— fazer requisições HTTP de dentro do banco (chamar Edge Functions);pgvector— embeddings e busca semântica (módulo 11);postgis— dados geográficos ("lojas num raio de 5 km");pg_stat_statements— estatísticas de consultas para otimização.
sqlcreate extension if not exists pg_cron; select cron.schedule( 'limpar-tarefas-antigas', '0 3 * * *', -- todo dia às 3h $$ delete from tasks where status = 'done' and updated_at < now() - interval '180 days' $$ );
10.6 Migrations: o banco no Git
Mudança de schema sem migration é mudança perdida. O fluxo profissional com a CLI:
bashnpx supabase migration new criar_task_history # edite o .sql gerado em supabase/migrations/ com o DDL npx supabase db reset # recria o banco local aplicando todas as migrations npx supabase db diff -f ajuste_x # ou: gere migration a partir de mudanças feitas no Studio local npx supabase link --project-ref XXXX npx supabase db push # aplica migrations pendentes no projeto remoto
10.7 Connection pooling (Supavisor)
Postgres suporta um número limitado de conexões diretas. Ambientes serverless (Vercel, Lambda) abrem conexões em rajadas — por isso o Supabase fornece o Supavisor, um pooler que multiplexa milhares de clientes em poucas conexões reais:
- Transaction mode (porta 6543) — para apps serverless; a conexão volta ao pool após cada transação (sem
prepared statementspersistentes); - Session mode (porta 5432 via pooler) — comportamento clássico, para servidores de longa duração;
- A API REST (PostgREST) já gerencia o próprio pool — essa escolha importa quando você conecta direto ao banco (Prisma, Drizzle, scripts).
Perguntas clássicas de entrevista pleno/sênior cobertas aqui: "como você investigaria uma query lenta?" (pg_stat_statements → EXPLAIN ANALYZE → índice), "o que é um trigger e um caso de uso?", "por que serverless + Postgres precisa de pooler?". Essas respostas valem para qualquer stack com Postgres, não só Supabase.
- Popule
taskscom 100 mil linhas (insert ... select generate_series(...)) e compare o EXPLAIN ANALYZE de uma busca porboard_idantes e depois do índice. - Converta todo o schema do DevBoard em migrations versionadas e suba num repositório Git.
- Crie um trigger que impeça mais de 100 tarefas com status 'todo' por quadro (levante exceção).
Nível expert: IA, multi-tenancy, testes e produção
- Construir busca semântica e RAG com pgvector;
- Arquitetar multi-tenancy segura para SaaS;
- Testar políticas com pgTAP e montar CI/CD com GitHub Actions;
- Aplicar o checklist de produção: segurança, backups, custos, observabilidade;
- Avaliar self-hosting.
11.1 pgvector: busca semântica e RAG
Embeddings são vetores numéricos que representam o significado de um texto. Com a extensão pgvector, o Postgres armazena e compara vetores — a base de busca semântica e de RAG (Retrieval-Augmented Generation: buscar trechos relevantes e entregá-los a um LLM como contexto).
sqlcreate extension if not exists vector; create table documents ( id bigint generated always as identity primary key, content text not null, embedding vector(1536) -- dimensão do modelo de embedding usado ); -- Índice HNSW: busca aproximada rápida em milhões de vetores create index on documents using hnsw (embedding vector_cosine_ops); -- Função de busca por similaridade (quanto menor a distância, mais parecido) create function match_documents(query_embedding vector(1536), match_count int) returns table (id bigint, content text, similarity float) language sql stable as $$ select id, content, 1 - (embedding <=> query_embedding) as similarity from documents order by embedding <=> query_embedding limit match_count; $$;
typescript// Edge Function: pipeline RAG resumido // 1. gerar embedding da pergunta numa API de embeddings // 2. buscar trechos parecidos const { data: docs } = await admin.rpc('match_documents', { query_embedding: embedding, match_count: 5 }) // 3. montar o prompt com os trechos e chamar o LLM const contexto = docs.map(d => d.content).join('\n---\n')
"Experiência com RAG/busca vetorial" explodiu nos requisitos de vagas desde 2023 e segue em alta. Um projeto "converse com seus documentos" (upload de PDF → chunks → embeddings → chat) usando só Supabase é hoje um dos itens de portfólio com melhor retorno por hora investida.
11.2 Multi-tenancy para SaaS
SaaS B2B = várias empresas (tenants/organizações) no mesmo banco, com isolamento absoluto. O padrão dominante no ecossistema Supabase:
- Tabelas
organizationseorganization_members (org_id, user_id, role); - Toda tabela de dados carrega
org_id; - RLS em toda tabela:
using (org_id in (select org_id from organization_members where user_id = (select auth.uid())))— de preferência encapsulado numa funçãosecurity definerou lido de um custom claim no JWT (via Auth Hook) para performance; - Índice composto começando por
org_idem todas as tabelas grandes; - Testes automatizados provando que o tenant A jamais lê o tenant B (a seguir).
11.3 Testando o banco com pgTAP
Política de RLS sem teste é promessa, não garantia. O framework pgTAP roda testes dentro do banco:
sql-- supabase/tests/rls_boards.test.sql begin; select plan(2); -- simula um usuário autenticado select tests.create_supabase_user('ana'); select tests.authenticate_as('ana'); insert into boards (owner_id, title) values (tests.get_supabase_uid('ana'), 'Meu quadro'); select results_eq( 'select count(*) from boards', array[1::bigint], 'Ana vê o próprio quadro' ); select tests.create_supabase_user('beto'); select tests.authenticate_as('beto'); select results_eq( 'select count(*) from boards', array[0::bigint], 'Beto NÃO vê o quadro da Ana' ); select * from finish(); rollback;
bashnpx supabase test db # roda os testes .sql localmente
(As funções auxiliares tests.* vêm do pacote comunitário supabase_test_helpers — instale como migration de teste.)
11.4 CI/CD com GitHub Actions
yaml# .github/workflows/deploy.yml (esqueleto) name: Deploy Supabase on: { push: { branches: [main] } } jobs: test-and-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: supabase/setup-cli@v1 - run: supabase start # stack local no runner - run: supabase test db # pgTAP: RLS testada a cada push - run: supabase link --project-ref ${{ secrets.SUPABASE_PROJECT_REF }} - run: supabase db push # migrations → produção - run: supabase functions deploy env: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
Fluxo completo de gente grande: branch → PR roda testes → merge na main → migrations e functions vão sozinhas para produção. Para times maiores, o recurso de branching do Supabase cria bancos de preview por PR.
11.5 Checklist de produção
| Área | Checklist |
|---|---|
| Segurança | RLS em todas as tabelas do schema público; nenhum service_role no client; Security Advisor do dashboard sem alertas; MFA no seu login do Supabase; search_path fixado em funções definer |
| Backups | Backups diários (automáticos nos planos pagos); PITR (point-in-time recovery) para produtos sérios; teste a restauração ao menos uma vez |
| Performance | Índices para FKs e políticas; Query Performance revisado; pooler correto para serverless; paginação em toda listagem |
| Observabilidade | Logs de API/DB/Functions no dashboard; alertas de uso; Sentry (ou similar) no frontend e nas functions |
| Custos | Conhecer limites do plano Free (pausa por inatividade!) vs. Pro; monitorar egress (tráfego de saída) e tamanho do banco; imagem otimizada = egress menor |
| Auth | Templates de e-mail com seu domínio (SMTP próprio); rate limits revisados; redirect URLs restritas |
11.6 Self-hosting: quando e como
O Supabase é open source — dá para rodar tudo em servidores seus com Docker Compose. Faz sentido quando há exigência regulatória de dados on-premise ou escala com equipe de infra dedicada. O trade-off: você vira responsável por atualizações, backups, segurança e disponibilidade de ~10 serviços (Postgres, GoTrue, PostgREST, Realtime, Storage, Kong…). Para a imensa maioria dos projetos, a nuvem gerenciada custa menos que o tempo de engenharia do self-host — mas saber que a opção existe é um argumento forte contra lock-in em discussões de arquitetura.
- Monte o pipeline "converse com seus documentos": upload de texto → divisão em chunks → embeddings →
match_documents→ resposta com LLM. - Escreva testes pgTAP para as políticas de
tasks(membro vê; não-membro não vê; só admin apaga). - Configure o workflow de CI acima num repositório real e faça um deploy completo via push.
Mercado de trabalho: transformando estudo em salário
- Um plano de portfólio com 3 projetos que provam competência;
- Um roteiro de preparação para entrevistas técnicas;
- Estratégia de posicionamento: onde procurar e como se apresentar.
12.1 Que vagas este conhecimento abre
| Perfil de vaga | Como o Supabase entra |
|---|---|
| Full-stack (React/Next + backend) | Você entrega o produto inteiro sozinho — perfil favorito de startups |
| Backend Postgres/Node | SQL, modelagem, RLS, funções e performance são o núcleo da vaga |
| Mobile (Flutter/React Native) | Supabase é backend popularíssimo no ecossistema mobile |
| Freelance / agência | MVPs em semanas: nicho lucrativo e em crescimento |
| Apps de IA | pgvector + Edge Functions = infraestrutura de RAG pronta |
Dica de busca: além de "Supabase", pesquise vagas por "PostgreSQL", "Postgres", "Next.js", "serverless" — o conhecimento desta apostila cobre grande parte desses requisitos.
12.2 Os 3 projetos de portfólio
Recrutadores não leem 10 repositórios; leem 3 READMEs. Qualidade e narrativa valem mais que quantidade.
Projeto 1 — DevBoard (o desta apostila)
Kanban colaborativo com auth, RLS por equipe, anexos e tempo real. Prova: fundamentos completos + segurança. Diferenciais: GIF do realtime no README, testes pgTAP, CI.
Projeto 2 — SaaS com pagamento
Qualquer micro-SaaS (ex.: gerador de link-na-bio, controle de assinaturas) com planos Free/Pro via Stripe em modo teste, webhook e gating de features por plano. Prova: você sabe gerar receita — a habilidade que empresas mais valorizam.
Projeto 3 — App de IA com RAG
"Converse com seus PDFs" ou busca semântica num acervo. Prova: você está na fronteira atual do mercado.
1) Uma frase dizendo o que o app faz; 2) GIF/screenshot em ação; 3) link do deploy funcionando (Vercel + Supabase free); 4) diagrama simples do schema; 5) seção "Decisões técnicas" explicando por que (RLS assim, índice assado); 6) como rodar localmente com supabase start. A seção 5 é a que gera conversa na entrevista.
12.3 Preparação para a entrevista técnica
Perguntas recorrentes — e o módulo onde você aprendeu a resposta:
- "Por que Supabase e não Firebase?" → M1 (relacional, SQL, open source, lock-in);
- "Explique RLS e um exemplo de política" → M6 (escreva uma de memória!);
- "Diferença entre anon key e service role?" → M2;
- "Como modelaria X?" → M3 (pense alto: entidades → relações → constraints);
- "Query lenta em produção, o que faz?" → M10 (Query Performance → EXPLAIN → índice);
- "Autenticação vs. autorização?" → M5/M6;
- "Como isolaria dados de clientes num SaaS?" → M11 (multi-tenancy);
- "O que é RAG?" → M11.
Treine falando em voz alta — explicar é uma habilidade separada de saber. Grave-se respondendo três dessas perguntas e ouça.
12.4 Posicionamento e prospecção
- LinkedIn: título com stack ("Desenvolvedor Full-stack · React · Next.js · PostgreSQL/Supabase"), projetos em destaque com links, 1 post curto por projeto contando um problema que você resolveu (o post sobre "como implementei RLS multi-tenant" atrai exatamente quem contrata isso);
- GitHub: os 3 projetos pinados, commits frequentes, README caprichado;
- Comunidades: Discord oficial do Supabase, comunidades BR de dev — responder dúvidas de RLS é networking técnico puro;
- Freelance: ofereça pacotes claros ("MVP com login, pagamentos e painel em 3 semanas") em vez de "faço sites"; a Workana/Upwork têm demanda constante de Supabase + Next.js;
- Open source: uma PR aceita em lib do ecossistema (mesmo docs) vale linha de currículo.
12.5 Roteiro de estudo sugerido (8 semanas)
| Semanas | Meta |
|---|---|
| 1–2 | Módulos 1–4 + DevBoard com CRUD funcionando |
| 3–4 | Módulos 5–6 + auth completa e RLS testada com 2 usuários |
| 5 | Módulos 7–8 + anexos e realtime no DevBoard (grave o GIF!) |
| 6 | Módulos 9–10 + migrations, índices, 1 Edge Function; deploy público |
| 7 | Módulo 11 + projeto de IA com pgvector |
| 8 | Módulo 12 + SaaS com Stripe teste, READMEs, LinkedIn atualizado, simulados de entrevista |
Ninguém é contratado por "ter estudado Supabase" — é contratado por demonstrar que resolve problemas com ele. Cada módulo desta apostila termina em algo demonstrável de propósito. Se você fez os exercícios, seu portfólio já existe: agora é polir, publicar e contar a história.
- Publique o DevBoard (Vercel + Supabase) e escreva o README no formato acima.
- Responda por escrito, sem consultar, as 8 perguntas de entrevista da seção 12.3 — depois confira nos módulos.
- Atualize LinkedIn e GitHub e envie a primeira candidatura (ou proposta de freela) esta semana.