Estávamos pagando $48/mês pelo Monday.com. Três assentos, plano Pro. E toda semana, alguém do time dizia algo como "Eu odeio isso." Não porque Monday.com seja ruim — é genuinamente um software impressionante. Mas estava fazendo cerca de 200 coisas que não precisávamos e não conseguia fazer as 4 coisas que realmente queremos. Então fizemos o que qualquer agência cheia de desenvolvedores com muitas opiniões faria: construímos o nosso próprio.

Este 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". Aqui está a história completa de como construir um kanban CRM customizado usando Astro, Supabase e uma dose saudável de ódio pela inchaço de SaaS.

Índice

Inside Our CRM Kanban: Por que Reconstruímos Monday.com em Astro + Supabase

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 contra nós. 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 de um deal referenciar um contato, e um projeto referenciar um deal. Monday.com pode fazer isso de certa forma com colunas vinculadas, mas é complicado. 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 visualização kanban não conseguia fazer o que queremos. Precisávamos ver deals agrupados por estágio E com código de cores por fonte (referência, orgânico, outbound). A visualização kanban do Monday.com deixa você agrupar por uma coluna de status. Só isso. Você não pode adicionar uma segunda dimensão visual sem fazer hack com convenções de nomenclatura.

Problema 3: Velocidade. Isso é subjetivo, mas Monday.com parecia lento para o que fazemos. Clique em um deal, espere o painel lateral carregar, role passando campos que não usamos, ache a nota que precisa. Cada interação tinha latência suficiente para gerar fricção.

Problema 4: Trajetória de custo. Em $48/mês para três pessoas, não é caro. Mas estávamos de olho em um quarto membro da equipe, e o preço do Monday.com salta para $60/mês pelo plano Pro com 5 assentos (você não pode comprar 4). Isso é $720/ano por 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 no Monday.com quem tinha "reclamado" o lead. O sistema de notificações não exibia isso claramente o suficiente, e nosso workaround hacky de se adicionar à coluna "People" não era confiável. Foi aí que abri VS Code em vez de Monday.com.

O que realmente precisávamos

Antes de escrever qualquer código, passamos 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:

  1. Kanban board com colunas para estágios de deal: Lead → Contactado → Proposta → Negociação → Ganho → Perdido
  2. Cartões de deal mostrando: nome do contato, valor do deal, tag de fonte (com código de cores), membro do time designado, dias no estágio atual
  3. Drag and drop entre colunas com persistência instantânea
  4. Visualização de detalhe do deal com notas (markdown), informações de contato e um simples log de atividade
  5. Sincronização em tempo real para que duas pessoas olhando o board vejam o mesmo estado
  6. Banco de dados de contatos com informações básicas (nome, email, empresa, notas)
  7. Autenticação simples — apenas nosso time, sem acesso público

É isso. Sem gráficos de Gantt. Sem rastreamento de tempo. Sem engine de automações. Sem 47 tipos de colunas diferentes. Só um kanban board apoiado por um banco de dados real com relacionamentos reais.

Escolhendo a Stack: Astro + Supabase

Somos uma agência 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 um "gerador de site estático" o subestima significativamente.

Desde Astro 4.x (e agora 5.x em 2025), renderização no servidor com rotas sob demanda é uma feature de primeira classe. Você pode construir aplicações dinâmicas completas. Usamos o modo de renderização híbrido do Astro: a maioria das páginas são renderizadas no servidor sob demanda, mas ainda podemos pré-renderizar coisas como a página de login.

Para o kanban board interativo em si, usamos uma island React. Esta é a feature assassina do Astro para apps como este — o shell da aplicação (nav, layout, verificações de auth) é renderizado no servidor com zero JS, e o kanban board monta como uma única island interativa com client:load.

Supabase foi a escolha de banco de dados por várias razões:

Feature Por que importava
Postgres por baixo Banco de dados relacional real, chaves estrangeiras reais, queries reais
Subscrições Realtime Suporte WebSocket built-in para atualizações ao vivo
Row-Level Security (RLS) Regras de auth no nível do banco de dados, não apenas no app
Biblioteca cliente JS API limpa, bom suporte TypeScript
Tier gratuito Nosso uso cabe confortavelmente no plano gratuito do Supabase
Opção de self-host Se um dia ultrapassarmos o tier gratuito, podemos executar nós mesmos

Brevemente consideramos outras opções:

Opção Por que não escolhemos
Firebase / Firestore NoSQL torna dados relacionais estranhos. Já sofremos antes.
PlanetScale Ótimo, mas sem realtime built-in. Precisaríamos de solução WebSocket separada.
Neon + Prisma Combinação sólida, mas Supabase nos dá auth + realtime + DB em um.
Construir em Next.js Conhecemos Next.js bem (construímos com ele regularmente), mas para uma ferramenta interna, a arquitetura de islands do Astro significava menos JS no cliente para as partes não-interativas.

Inside Our CRM Kanban: Por que Reconstruímos Monday.com em Astro + Supabase - arquitetura

Design do Banco de Dados: Mantendo Tudo Bem Simples

O schema tem quatro tabelas. É isso.

-- Contacts: as pessoas e empresas com as quais conversamos
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 items 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 ordenação dentro de uma coluna
  stage_entered_at timestamptz default now(),
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Activity log: log simples apenas-append
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 pequenas favoritas. Toda vez que um deal muda para um novo estágio, atualizamos esse timestamp. Isso nos deixa calcular "dias no estágio atual" sem fazer query no activity log. Simples, rápido, útil.

O campo position lida com ordenação dentro de colunas kanban. Quando você arrasta um cartão entre dois outros, calculamos um novo valor de position. Usamos espaçamento de inteiros (positions aumentam em 1000) para raramente precisar rebalancear.

Construindo o Kanban Board

O kanban board é um componente React montado como uma island Astro. Usamos @dnd-kit/core para drag and drop porque é a biblioteca DnD mais acessível e bem-mantida no ecossistema React a partir de 2025.

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);

  // Subscrever a mudanças em tempo real
  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 atualização otimista — ela imediatamente atualiza o estado local, depois envia a atualização para Supabase. Se a atualização do banco de dados falhar, ela reverte. Isso faz o board parecer instantâneo.

const moveDeal = async (dealId: string, newStage: string, newPosition: number) => {
  // Atualização otimista
  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) {
    // Revert — refetch do servidor
    await refreshDeals();
    toast.error('Failed to move deal');
  }

  // Log da atividade
  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>

Aquela diretiva client:load está fazendo o trabalho pesado. O layout, nav, e shell da página são todos HTML renderizado no servidor. O kanban board em si hidrata no cliente. Isso significa que o carregamento inicial da página é rápido — o navegador obtém HTML imediatamente, e o board interativo ativa logo depois.

Atualizações em Tempo Real com Supabase Realtime

Este foi o feature que tornou Supabase a escolha clara para esse projeto. Quando um membro do time move um deal, os outros membros do time o veem se mover em tempo real. Sem necessidade de refresh.

// 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);
    };
  }, []);
}

Uma pegadinha: quando VOCÊ move um deal, você recebe sua própria mudança de volta via subscrição realtime. Se você não tiver cuidado, isso causa um glitch visual onde o cartão salta. Nós tratamos disso marcando atualizações otimistas com um timestamp e ignorando eventos realtime que correspondem a mudanças locais recentes. São alguns linhas a mais de código mas torna a UX sólida.

Autenticação e Row-Level Security

Como essa é uma ferramenta interna, auth é simples. Usamos Supabase Auth com email/senha. Três contas. Sem fluxo de sign-up — criamos as contas manualmente no dashboard do Supabase.

Row-Level Security é onde fica interessante. Mesmo sendo uma ferramenta interna, RLS significa nosso banco de dados não vaza dados acidentalmente mesmo se nos equivocarmos no 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, tudo bem. Se um dia crescemos para um tamanho onde precisamos de permissões baseadas em roles, a infraestrutura RLS já está lá. Poderíamos apertar as políticas.

Deploy e Custos de Hospedagem

Aqui está a parte divertida. Vamos falar sobre dinheiro.

Serviço Plano Custo Mensal
Supabase Tier gratuito $0
Vercel (hospedando Astro SSR) Plano Pro (já tínhamos) $0 incremental
Domínio Subdomínio do domínio existente $0
Total $0/mês

Já estávamos no plano Pro da Vercel para projetos de clientes, então fazer deploy de um app SSR a mais não nos custa nada extra. O tier gratuito do Supabase nos dá 500MB de armazenamento de banco de dados, 50.000 usuários ativos mensais (temos 3), e conexões realtime. Estamos usando talvez 1% da capacidade do tier gratuito.

Compare com Monday.com:

Monday.com Nosso CRM Customizado
Custo mensal $48 (3 assentos, Pro) $0
Custo anual $576 $0
Tempo de construção 0 horas ~20 horas
Manutenção 0 horas/mês ~1 hora/mês

Na nossa taxa horária interna, 20 horas de tempo de dev vale muito mais que $576/ano. Mas essa conta perde o ponto. Construímos isso em parte porque queríamos, em parte porque é uma ferramenta melhor para nosso fluxo de trabalho específico, e em parte porque aquelas 20 horas nos ensinaram coisas que temos usado desde então em projetos de clientes. Desde então aplicamos arquiteturas Astro + Supabase similares para aplicações apoiadas por headless CMS que construímos para clientes.

O que Faríamos Diferente

Passou cerca de quatro meses desde que lançamos v1. Aqui está o que eu mudaria:

Usar Zustand desde o Início

Começamos com useState e useContext built-in do React para gerenciamento de estado. Quando adicionamos sync em tempo real, atualizações otimistas e lógica de rollback, o código de gerenciamento de estado ficou complicado. Migramos para Zustand depois de duas semanas. Deveria ter começado lá.

Adicionar Busca Mais Cedo

Não construímos busca até a semana três, e aquelas três semanas digitalizando manualmente colunas para um deal específico foram irritantes. Uma simples query ilike no Supabase teria levado 30 minutos para implementar.

Atalhos de Teclado

Ainda não adicionamos, mas queremos. Pressione N para criar um novo deal, / para buscar, 1-6 para filtrar por estágio. Pequenas coisas que se somam quando você está na ferramenta várias vezes ao dia.

Melhor Visualização Mobile

O kanban board funciona em mobile, tecnicamente. Mas seis colunas não cabem em uma tela de telefone. Precisamos de uma visualização de lista para mobile. Não priorizamos porque raramente checamos o CRM em nossos telefones, mas seria legal.

FAQ

Quanto tempo levou para construir o kanban board do CRM?

A primeira versão usável levou cerca de 20 horas espalhadas por um fim de semana e alguns períodos noturnos. Isso nos deu o kanban board, detalhes de deal, drag and drop, e auth básica. Provavelmente gastamos mais 10 horas desde então em melhorias como busca, estilos mobile melhores, e correções de bugs.

Por que Astro em vez de Next.js para um app dinâmico?

A arquitetura de islands do Astro significa que as partes não-interativas do nosso app (layout, nav, páginas estáticas) enviam zero JavaScript. O kanban board em si é uma island React que hidrata ao carregar. Para uma ferramenta interna onde a superfície interativa se concentra em um componente, isso é um ótimo encaixe. Usamos Next.js para projetos de clientes onde a interatividade é mais distribuída entre páginas.

O tier gratuito do Supabase é realmente suficiente para um CRM?

Para um time pequeno, absolutamente. Temos talvez 200 deals, 150 contatos, e alguns milhares de entradas de activity log. Isso são kilobytes de dados. O tier gratuito do Supabase te dá 500MB de armazenamento, que não atingiremos por anos. O cap de conexões realtime também é generoso — você obtém até 200 conexões simultâneas no plano gratuito.

E quanto a backups?

Supabase inclui backups diários no plano Pro ($25/mês), mas estamos no tier gratuito. Executamos um pg_dump semanal via cron job em um VPS de $5/mês que já tínhamos. Não é glamouroso, mas funciona. Também temos um clone de projeto Supabase para o qual 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 bem com políticas RLS mais apertadas e lógica de UI baseada em roles. Além disso, você começaria a querer features como automações, workflows customizados, e reportagem que exigiria esforço de engenharia sério. Nesse ponto, uma ferramenta CRM dedicada faz mais sentido — só talvez não Monday.com.

Como é o desempenho do sync em tempo real?

Supabase Realtime usa WebSockets por baixo, e para nosso caso de uso (3 usuários simultâneos, atualizações com baixa frequência), é essencialmente instantâneo. Medimos a latência end-to-end de um usuário arrastando um cartão para outro usuário ver a atualização: tipicamente 80-150ms. Isso é mais rápido do que conseguimos perceber.

Vocês consideraram alternativas de 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 mais simples focado em kanban, talvez tivéssemos escolhido esse caminho.

Vocês construiriam ferramentas internas customizadas para clientes também?

Temos, na verdade. Vários clientes vieram a nós depois de crescer além de ferramentas como Monday.com, Notion, ou Airtable para workflows específicos. Tipicamente construímos essas com Astro ou Next.js no frontend e Supabase ou um headless CMS no backend. Se soa como algo que você precisa, devemos conversar.