Por que Matamos Monday.com e Construímos Nosso CRM em Astro + Supabase
Sua fatura chega: $48/mês pelo Monday.com. Três assentos, plano Pro. E em toda retrospectiva de sprint, alguém murmura a mesma frase — "Odeio essa coisa". Não porque o software está quebrado. Monday.com é genuinamente impressionante. Só que faz 200 coisas que seu pipeline não precisa e falha nas 4 workflows que você realmente executa. As colunas não ordenam do jeito que seu processo de vendas se move. As automações disparam duas vezes. A visualização mobile faz sua gerente de contas querer jogar o telefone pela janela. Então fizemos o que qualquer dev shop com créditos Supabase e opiniões demais faria: reconstruímos. Onze dias, 1.200 linhas de Astro, zero arrependimentos. Aqui está o schema, o drag controller, e a conta que fez nosso CFO sorrir.
Isso não é um post "somos mais inteligentes que Monday.com". É um post "tínhamos necessidades muito específicas, uma stack que já conhecíamos, e um fim de semana" post. Aqui está a história completa de construir um kanban CRM customizado usando Astro, Supabase, e uma dose saudável de raiva pela inflação de SaaS.
Índice
- Por que Monday.com Parou de Funcionar para Nós
- O Que Realmente Precisávamos
- Escolhendo a Stack: Astro + Supabase
- Design do Banco de Dados: Mantendo Tudo Simples
- Construindo o Kanban Board
- Atualizações em Tempo Real com Supabase Realtime
- Autenticação e Row-Level Security
- Deploy e Custos de Hospedagem
- O Que Faríamos Diferente
- FAQ

Por que Monday.com Parou de Funcionar para Nós
Deixe-me ser específico sobre nossas frustrações, porque reclamações vagas sobre ferramentas SaaS são inúteis.
Problema 1: O modelo de dados estava nos lutando. Monday.com pensa em "boards" e "items" com "columns". Nossa agência pensa em deals, contatos e projetos — três entidades distintas com relacionamentos entre elas. Precisávamos que um deal referenciasse um contato, e um projeto referenciasse um deal. Monday.com pode meio que fazer isso com colunas vinculadas, mas é desajeitado. Toda vez que alguém criava um novo deal, tinha que vinculá-lo manualmente ao contato correto. As pessoas esqueciam. Os dados ficavam bagunçados.
Problema 2: A view kanban não conseguia fazer o que queríamos. Precisávamos ver deals agrupados por stage E color-coded por source (referral, organic, outbound). A view kanban do Monday.com deixa agrupar por uma coluna de status. Só isso. Você não consegue sobrepor uma segunda dimensão visual sem hackear com convenções de naming.
Problema 3: Velocidade. Esse é subjetivo, mas Monday.com se sentia lento para o que estávamos fazendo. Clica em um deal, espera o painel lateral carregar, scroll passando campos que não usamos, encontra a nota que precisa. Toda interação tinha latência o suficiente para parecer friction.
Problema 4: Trajetória de custos. Em $48/mês para três pessoas, não é caro. Mas estávamos de olho em um quarto membro do time, e o preço de Monday.com pula para $60/mês no plano Pro com 5 assentos (você não consegue comprar 4). São $720/ano para uma ferramenta que estávamos ativamente reclamando.
O Ponto de Virada
O gatilho real foi vergonhosamente mundano. Um cliente em potencial nos enviou um email, e dois membros do time responderam porque nenhum conseguia dizer pelo Monday.com quem tinha "reclamado" o lead. O sistema de notificações não destacava claramente, e nosso workaround hacky de adicionar você mesmo em uma coluna "People" não era confiável. Foi quando abri VS Code em vez de Monday.com.
O Que Realmente Precisávamos
Antes de escrever qualquer código, gastamos cerca de uma hora listando exatamente o que nosso CRM precisava fazer. Não o que seria legal. O que era realmente necessário.
Aqui está a lista:
- Kanban board com colunas para deal stages: Lead → Contacted → Proposal → Negotiation → Won → Lost
- Deal cards mostrando: nome do contato, valor do deal, source tag (color-coded), membro do time atribuído, dias no stage atual
- Drag and drop entre colunas com persistência instantânea
- Deal detail view com notas (markdown), informações de contato, e um simples activity log
- Sincronização em tempo real para que duas pessoas olhando o board vejam o mesmo estado
- Banco de dados de contatos com informações básicas (nome, email, empresa, notas)
- Auth simples — apenas nosso time, sem acesso público
É tudo. Sem Gantt charts. Sem time tracking. Sem engine de automações. Sem 47 tipos diferentes de colunas. Apenas um kanban board apoiado por um banco de dados real com relacionamentos reais.
Escolhendo a Stack: Astro + Supabase
Somos uma dev shop de desenvolvimento Astro, então Astro era o ponto de partida óbvio. Mas vale a pena explicar por que realmente faz sentido aqui, porque a reputação do Astro como "static site generator" o subestima significativamente.
Desde Astro 4.x (e agora 5.x em 2026), server-side rendering com rotas on-demand é um recurso de primeira classe. Você pode construir aplicações dinâmicas completas. Usamos modo de rendering híbrido do Astro: a maioria das páginas é server-rendered on request, mas ainda podemos pre-renderizar coisas como a página de login.
Para o próprio kanban board interativo, usamos uma React island. Esse é o recurso killer do Astro para apps como esse — a shell da aplicação (nav, layout, auth checks) é server-rendered com zero JS, e o kanban board monta como uma única island interativa com client:load.
Supabase era a escolha de banco de dados por várias razões:
| Feature | Por que Importava |
|---|---|
| Postgres sob o capô | Banco de dados relacional real, foreign keys reais, queries reais |
| Realtime subscriptions | Suporte WebSocket built-in para atualizações ao vivo |
| Row-Level Security (RLS) | Regras de auth no nível do banco, não apenas no app |
| JS client library | API limpa, bom suporte TypeScript |
| Free tier | Nosso uso cabe confortavelmente no plano gratuito da Supabase |
| Self-host option | Se algum dia excedermos o tier gratuito, podemos rodar nós mesmos |
Brevemente consideramos outras opções:
| Option | Por que Passamos |
|---|---|
| Firebase / Firestore | NoSQL torna dados relacionais desajeitados. Já fomos queimados antes. |
| PlanetScale | Ótimo, mas sem realtime built-in. Precisaria de solução WebSocket separada. |
| Neon + Prisma | Combinação sólida, mas Supabase nos dá auth + realtime + DB em um. |
| Building on Next.js | Conhecemos Next.js bem (frequentemente construímos com ele), mas para uma ferramenta interna, a arquitetura de islands do Astro significava menos JS client-side para as partes não-interativas. |

Design do Banco de Dados: Mantendo Tudo Simples
O schema tem quatro tabelas. Só isso.
-- Contacts: as pessoas e empresas com as quais falamos
create table contacts (
id uuid default gen_random_uuid() primary key,
name text not null,
email text,
company text,
phone text,
notes text,
created_at timestamptz default now()
);
-- Deals: os itens do pipeline no nosso kanban board
create table deals (
id uuid default gen_random_uuid() primary key,
contact_id uuid references contacts(id) on delete set null,
title text not null,
value integer, -- armazenado em centavos
stage text not null default 'lead'
check (stage in ('lead', 'contacted', 'proposal', 'negotiation', 'won', 'lost')),
source text
check (source in ('referral', 'organic', 'outbound', 'repeat', 'other')),
assigned_to uuid references auth.users(id),
position integer not null default 0, -- para ordenar dentro de uma coluna
stage_entered_at timestamptz default now(),
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Activity log: simples append-only log
create table activities (
id uuid default gen_random_uuid() primary key,
deal_id uuid references deals(id) on delete cascade,
user_id uuid references auth.users(id),
action text not null, -- 'stage_change', 'note', 'created', etc.
details jsonb,
created_at timestamptz default now()
);
-- Deal notes: notas markdown anexadas a deals
create table deal_notes (
id uuid default gen_random_uuid() primary key,
deal_id uuid references deals(id) on delete cascade,
user_id uuid references auth.users(id),
content text not null,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
O campo stage_entered_at em deals é uma das minhas decisões favoritas. Toda vez que um deal se move para um novo stage, atualizamos esse timestamp. Isso nos deixa calcular "dias no stage atual" sem consultar o activity log. Simples, rápido, útil.
O campo position trata da ordenação dentro das colunas kanban. Quando você arrasta um card entre dois outros, calculamos um novo valor de posição. Usamos espaçamento inteiro (posições incrementam de 1000) então raramente precisamos rebalancear.
Construindo o Kanban Board
O kanban board é um componente React montado como uma Astro island. Usamos @dnd-kit/core para drag and drop porque é a biblioteca DnD mais acessível e bem-mantida do ecossistema React em 2026.
Aqui está a estrutura simplificada:
// src/components/KanbanBoard.tsx
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { useState } from 'react';
import { KanbanColumn } from './KanbanColumn';
import { DealCard } from './DealCard';
import { useDeals } from '../hooks/useDeals';
import { useRealtimeDeals } from '../hooks/useRealtimeDeals';
const STAGES = ['lead', 'contacted', 'proposal', 'negotiation', 'won', 'lost'];
const SOURCE_COLORS: Record<string, string> = {
referral: '#10b981',
organic: '#3b82f6',
outbound: '#f59e0b',
repeat: '#8b5cf6',
other: '#6b7280',
};
export function KanbanBoard() {
const { deals, moveDeal } = useDeals();
const [activeId, setActiveId] = useState<string | null>(null);
// Subscribe to realtime changes
useRealtimeDeals();
const handleDragEnd = async (event) => {
const { active, over } = event;
if (!over) return;
const dealId = active.id;
const newStage = over.data.current?.stage;
const newPosition = over.data.current?.position;
if (newStage) {
await moveDeal(dealId, newStage, newPosition);
}
setActiveId(null);
};
return (
<DndContext onDragStart={({ active }) => setActiveId(active.id)} onDragEnd={handleDragEnd}>
<div className="kanban-grid">
{STAGES.map((stage) => (
<KanbanColumn
key={stage}
stage={stage}
deals={deals.filter((d) => d.stage === stage)}
sourceColors={SOURCE_COLORS}
/>
))}
</div>
<DragOverlay>
{activeId ? <DealCard deal={deals.find((d) => d.id === activeId)} overlay /> : null}
</DragOverlay>
</DndContext>
);
}
A função moveDeal faz uma otimistic update — ela imediatamente atualiza o estado local, depois envia a atualização para Supabase. Se a atualização do banco falha, faz rollback. Isso faz o board parecer instantâneo.
const moveDeal = async (dealId: string, newStage: string, newPosition: number) => {
// Otimistic update
setDeals((prev) =>
prev.map((d) =>
d.id === dealId
? { ...d, stage: newStage, position: newPosition, stage_entered_at: new Date().toISOString() }
: d
)
);
const { error } = await supabase
.from('deals')
.update({
stage: newStage,
position: newPosition,
stage_entered_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', dealId);
if (error) {
// Rollback — refetch do servidor
await refreshDeals();
toast.error('Falha ao mover deal');
}
// Log the activity
await supabase.from('activities').insert({
deal_id: dealId,
user_id: currentUser.id,
action: 'stage_change',
details: { from: previousStage, to: newStage },
});
};
A página Astro que hospeda isso é mínima:
---
// src/pages/board.astro
import Layout from '../layouts/App.astro';
import { KanbanBoard } from '../components/KanbanBoard';
import { getSession } from '../lib/auth';
const session = await getSession(Astro.request);
if (!session) return Astro.redirect('/login');
---
<Layout title="Deal Board">
<KanbanBoard client:load />
</Layout>
Esse client:load directive está fazendo o trabalho pesado. O layout, nav, e page shell são todos HTML server-rendered. O kanban board em si hydrata no cliente. Isso significa o carregamento da página inicial é rápido — o navegador recebe HTML imediatamente, e o board interativo inicia logo depois.
Atualizações em Tempo Real com Supabase Realtime
Esse foi o recurso que tornou Supabase a escolha clara para esse projeto. Quando um membro do time move um deal, os outros membros do time veem o movimento em tempo real. Sem refresh necessário.
// src/hooks/useRealtimeDeals.ts
import { useEffect } from 'react';
import { supabase } from '../lib/supabase';
import { useDealsStore } from '../stores/deals';
export function useRealtimeDeals() {
const { updateDealLocally, addDealLocally, removeDealLocally } = useDealsStore();
useEffect(() => {
const channel = supabase
.channel('deals-changes')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'deals' },
(payload) => {
switch (payload.eventType) {
case 'UPDATE':
updateDealLocally(payload.new);
break;
case 'INSERT':
addDealLocally(payload.new);
break;
case 'DELETE':
removeDealLocally(payload.old.id);
break;
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
}
Um gotcha: quando VOCÊ move um deal, você recebe sua própria mudança de volta via realtime subscription. Se você não for cuidadoso, isso causa um glitch visual onde o card pula. Tratamos disso ao tagear updates otimistas com um timestamp e ignorando eventos realtime que correspondem a mudanças locais recentes. São alguns poucos passos extras de código mas faz a UX parecer sólida.
Autenticação e Row-Level Security
Como essa é uma ferramenta interna, auth é simples. Usamos Supabase Auth com email/password. Três contas. Sem fluxo de sign-up — criamos as contas manualmente no dashboard Supabase.
Row-Level Security é onde fica interessante. Mesmo que seja uma ferramenta interna, RLS significa nosso banco não acidentalmente vazará dados nem que screw up o código da aplicação.
-- Apenas usuários autenticados podem ver deals
alter table deals enable row level security;
create policy "Usuários autenticados podem ler todos os deals"
on deals for select
to authenticated
using (true);
create policy "Usuários autenticados podem inserir deals"
on deals for insert
to authenticated
with check (true);
create policy "Usuários autenticados podem atualizar deals"
on deals for update
to authenticated
using (true);
create policy "Usuários autenticados podem deletar deals"
on deals for delete
to authenticated
using (true);
Sim, essas políticas são permissivas — qualquer usuário autenticado pode fazer qualquer coisa. Para um time de três pessoas, isso é fine. Se algum dia crescermos até um tamanho onde precisamos de permissões baseadas em role, a infraestrutura RLS já está lá. Seria só apertar as políticas.
Deploy e Custos de Hospedagem
Aqui está a parte divertida. Vamos falar dinheiro.
| Serviço | Plano | Custo Mensal |
|---|---|---|
| Supabase | Free tier | $0 |
| Vercel (hospedando Astro SSR) | Pro plan (já tínhamos) | $0 incremental |
| Domínio | subdomínio de domínio existente | $0 |
| Total | $0/mês |
Já estávamos no plano Pro do Vercel para projetos de clientes, então fazer deploy de um app SSR a mais não custa nada extra. O free tier da Supabase nos dá 500MB de storage de banco de dados, 50.000 monthly active users (temos 3), e realtime connections. Estamos usando talvez 1% da capacidade do free tier.
Compare com Monday.com:
| Monday.com | Nosso CRM Customizado | |
|---|---|---|
| Custo mensal | $48 (3 assentos, Pro) | $0 |
| Custo anual | $576 | $0 |
| Tempo de build | 0 horas | ~20 horas |
| Manutenção | 0 horas/mês | ~1 hora/mês |
Nossa taxa horária interna, 20 horas de dev time vale muito mais que $576/ano. Mas essa matemática perde o ponto. Construímos isso em parte porque queríamos, em parte porque é uma ferramenta melhor para nosso workflow específico, e em parte porque essas 20 horas nos ensinaram coisas que desde então usamos em projetos de clientes. Desde então aplicamos arquiteturas similares de Astro + Supabase para aplicações suportadas por headless CMS que construímos para clientes.
O Que Faríamos Diferente
Já passaram cerca de quatro meses desde que lançamos v1. Aqui está o que eu mudaria:
Use Zustand Desde o Dia 1
Começamos com useState do React built-in e useContext para state management. Pelo tempo que adicionamos realtime sync, otimistic updates, e rollback logic, o código de state management estava emaranhado. Migramos para Zustand depois de duas semanas. Deveria ter começado aí.
Add Search Mais Cedo
Não construímos search até semana três, e essas três semanas de manualmente varrer colunas procurando um deal específico foram chatas. Uma simples query ilike em Supabase teria levado 30 minutos para implementar.
Keyboard Shortcuts
Ainda não adicionamos, mas queremos. Pressione N para criar um novo deal, / para search, 1-6 para filtrar por stage. Pequenas coisas que se acumulam quando você está na ferramenta múltiplas vezes ao dia.
Melhor Mobile View
O kanban board funciona em mobile, tecnicamente. Mas seis colunas não cabem em tela de telefone. Precisamos de uma list view para mobile. Não priorizamos porque raramente checamos o CRM nos nossos telefones, mas seria legal.
FAQ
Quanto tempo levou para construir o CRM kanban board?
A primeira versão usável levou cerca de 20 horas espalhadas por um fim de semana e alguns noturnos. Isso nos deu o kanban board, deal details, drag and drop, e auth básica. Provavelmente gastamos outras 10 horas desde então em melhorias como search, estilos mobile melhores, e bug fixes.
Por que Astro em vez de Next.js para um app dinâmico?
A arquitetura de islands do Astro significa as partes não-interativas do nosso app (layout, nav, static pages) enviam zero JavaScript. O kanban board em si é uma React island que hydrata ao carregamento. Para uma ferramenta interna onde a superfície interativa é focada em um componente, esse é um grande fit. Usamos Next.js para projetos de clientes onde a interatividade é mais distribuída entre páginas.
O free tier da Supabase é realmente suficiente para um CRM?
Para um time pequeno, absolutamente. Temos talvez 200 deals, 150 contatos, e alguns milhares de activity log entries. São kilobytes de dados. O free tier da Supabase te dá 500MB de storage, que não vamos atingir por anos. O cap de realtime connections é generoso também — você recebe até 200 conexões concorrentes no plano free.
E quanto a backups?
Supabase inclui backups diários no plano Pro ($25/mês), mas estamos no tier free. Rodamos um pg_dump semanal via cron job em um VPS de $5/mês que já tínhamos. Não é glamoroso, mas funciona. Também temos um clone de projeto Supabase que podemos restaurar se algo der errado.
Essa abordagem pode funcionar para um time maior que 3 pessoas?
Até talvez 10-15 pessoas, acho que isso funcionaria com políticas RLS mais apertadas e alguma lógica de UI baseada em role. Além disso, você começaria a querer features como automações, workflows customizados, e reporting que levaria esforço sério de engenharia. Nesse ponto, uma ferramenta CRM dedicada faz mais sentido — apenas talvez não Monday.com.
Como o realtime sync performa?
Supabase Realtime usa WebSockets sob o capô, e para nosso caso de uso (3 usuários concorrentes, atualizações low-frequency), é essencialmente instantâneo. Medimos a latência end-to-end de um usuário arrastando um card até outro usuário vendo a atualização: tipicamente 80-150ms. É mais rápido que conseguimos perceber.
Você considerou alternativas CRM open-source como Twenty ou Folk?
Olhamos para Twenty (o CRM open-source que lançou em 2024) e é impressionante. Mas é um CRM completo com muito mais features que precisávamos, e self-hosting requer mais infraestrutura. Nosso objetivo era construir exatamente o que precisávamos e nada mais. Se Twenty tivesse existido quando começamos e tivesse um modo kanban-focused mais simples, talvez tivéssemos ido por essa rota.
Você construiria ferramentas internas customizadas para clientes também?
Temos construído, na verdade. Vários clientes vieram até nós depois de outgrowing ferramentas como Monday.com, Notion, ou Airtable para workflows específicos. Geralmente construímos essas com Astro ou Next.js no frontend e Supabase ou um headless CMS no backend. Se isso soa como algo que você precisa, devemos conversar.