Por Qué Eliminamos Monday.com y Construimos Nuestro CRM en Astro + Supabase
Tu factura llega: $48/mes por Monday.com. Tres asientos, plan Pro. Y cada sprint review, alguien murmura la misma línea — "Odio esta cosa". No porque el software esté roto. Monday.com es genuinamente impresionante. Solo hace 200 cosas que tu pipeline no necesita y falla en los 4 flujos de trabajo que realmente ejecutas. Las columnas no se ordenan como se mueve tu proceso de ventas. Las automaciones se disparan dos veces. La vista móvil hace que tu gerente de cuenta quiera tirar su teléfono. Así que hicimos lo que haría cualquier tienda de desarrollo con créditos de Supabase y demasiadas opiniones: lo reconstruimos. Once días, 1,200 líneas de Astro, cero arrepentimientos. Aquí está el schema, el controlador de arrastre, y la factura que hizo sonreír a nuestro CFO.
Este no es un post "somos más inteligentes que Monday.com". Es un "teníamos necesidades muy específicas, un stack que ya conocíamos, y un fin de semana" post. Aquí está la historia completa de construir un tablero kanban CRM personalizado usando Astro, Supabase, y una dosis saludable de desprecio hacia el bloat de SaaS.
Tabla de Contenidos
- Por Qué Monday.com Dejó de Funcionar para Nosotros
- Lo Que Realmente Necesitábamos
- Eligiendo el Stack: Astro + Supabase
- Diseño de Base de Datos: Mantenerlo Estúpidamente Simple
- Construyendo el Tablero Kanban
- Actualizaciones en Tiempo Real con Supabase Realtime
- Autenticación y Seguridad a Nivel de Fila
- Despliegue y Costos de Hosting
- Lo Que Haríamos Diferente
- Preguntas Frecuentes

Por Qué Monday.com Dejó de Funcionar para Nosotros
Permíteme ser específico sobre nuestras frustraciones, porque las quejas vagas sobre herramientas SaaS son inútiles.
Problema 1: El modelo de datos estaba en contra nuestro. Monday.com piensa en "boards" e "items" con "columnas". Nuestra agencia piensa en deals, contactos, y proyectos — tres entidades distintas con relaciones entre ellas. Necesitábamos un deal para referenciar un contacto, y un proyecto para referenciar un deal. Monday.com puede hacer esto medio bien con columnas vinculadas, pero es torpe. Cada vez que alguien creaba un nuevo deal, tenía que vincularlo manualmente al contacto correcto. La gente olvidaba. Los datos se volvían un desastre.
Problema 2: La vista kanban no podía hacer lo que queríamos. Necesitábamos ver deals agrupados por etapa Y codificados por color según la fuente (referral, organic, outbound). La vista kanban de Monday.com te deja agrupar por una columna de estado. Eso es todo. No puedes añadir una segunda dimensión visual sin hackarlo con convenciones de nombres.
Problema 3: Velocidad. Este es subjetivo, pero Monday.com se sentía lento para lo que estábamos haciendo. Haz clic en un deal, espera a que se cargue el panel lateral, desplázate pasando campos que no usas, encuentra la nota que necesitas. Cada interacción tenía suficiente latencia para sentir como fricción.
Problema 4: Trayectoria de costos. A $48/mes para tres personas, no es caro. Pero estábamos considerando agregar un cuarto miembro del equipo, y los precios de Monday.com saltan a $60/mes para el plan Pro con 5 asientos (no puedes comprar 4). Eso es $720/año por una herramienta de la que estábamos activamente quejándonos.
El Punto de Quiebre
El disparador real fue vergonzosamente mundano. Un cliente potencial nos escribió, y dos miembros del equipo respondieron ambos porque ninguno podía saber desde Monday.com quién había "reclamado" el lead. El sistema de notificaciones no lo mostraba claramente suficiente, y nuestro hacky workaround de agregarte a una columna "People" no era confiable. Fue cuando abrí VS Code en lugar de Monday.com.
Lo Que Realmente Necesitábamos
Antes de escribir código, pasamos aproximadamente una hora listando exactamente qué necesitaba hacer nuestro CRM. No qué sería agradable. Qué era realmente necesario.
Aquí está la lista:
- Tablero kanban con columnas para etapas de deal: Lead → Contacted → Proposal → Negotiation → Won → Lost
- Tarjetas de deal mostrando: nombre del contacto, valor del deal, etiqueta de fuente (codificada por color), miembro del equipo asignado, días en etapa actual
- Arrastrar y soltar entre columnas con persistencia instantánea
- Vista de detalle de deal con notas (markdown), info de contacto, y un simple registro de actividad
- Sincronización en tiempo real para que dos personas viendo el board vean el mismo estado
- Base de datos de contactos con información básica (nombre, email, empresa, notas)
- Autenticación simple — solo nuestro equipo, sin acceso público
Eso es todo. Sin gráficos de Gantt. Sin seguimiento de tiempo. Sin motor de automaciones. Sin 47 tipos diferentes de columnas. Solo un tablero kanban respaldado por una base de datos real con relaciones reales.
Eligiendo el Stack: Astro + Supabase
Somos una tienda de desarrollo de Astro, así que Astro fue la opción obvia. Pero vale la pena explicar por qué realmente tiene sentido aquí, porque la reputación de Astro como "generador de sitios estáticos" la subestima significativamente.
Desde Astro 4.x (y ahora 5.x en 2026), el renderizado del lado del servidor con rutas bajo demanda es una característica de primera clase. Puedes construir aplicaciones completamente dinámicas. Usamos el modo de renderizado híbrido de Astro: la mayoría de las páginas se renderan del lado del servidor bajo demanda, pero aún podemos pre-renderizar cosas como la página de login.
Para el tablero kanban interactivo en sí, usamos una isla de React. Esta es la característica asesina de Astro para aplicaciones como esta — el shell de la aplicación (nav, layout, verificaciones de auth) se renderiza del lado del servidor con cero JS, y el tablero kanban se monta como una única isla interactiva con client:load.
Supabase fue la opción de base de datos por varias razones:
| Característica | Por Qué Importaba |
|---|---|
| Postgres bajo el capó | Base de datos relacional real, foreign keys reales, queries reales |
| Suscripciones en tiempo real | Soporte WebSocket incorporado para actualizaciones en vivo |
| Row-Level Security (RLS) | Reglas de auth a nivel de base de datos, no solo a nivel de app |
| Librería cliente JS | API limpia, buen soporte de TypeScript |
| Free tier | Nuestro uso cabe cómodamente en el plan gratuito de Supabase |
| Opción de auto-hosting | Si superamos el free tier, podemos ejecutarlo nosotros mismos |
Consideramos brevemente otras opciones:
| Opción | Por Qué Pasamos |
|---|---|
| Firebase / Firestore | NoSQL hace los datos relacionales incómodos. Hemos sido quemados antes. |
| PlanetScale | Excelente, pero sin tiempo real incorporado. Necesitaría una solución WebSocket separada. |
| Neon + Prisma | Combo sólido, pero Supabase nos da auth + realtime + DB en uno. |
| Construyendo en Next.js | Conocemos bien Next.js (lo construimos regularmente), pero para una herramienta interna, la arquitectura de islas de Astro significaba menos JS del lado del cliente para las partes no-interactivas. |

Diseño de Base de Datos: Mantenerlo Estúpidamente Simple
El schema tiene cuatro tablas. Eso es todo.
-- Contacts: las personas y empresas con las que hablamos
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: los items del pipeline en nuestro tablero kanban
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, -- almacenado en 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 una columna
stage_entered_at timestamptz default now(),
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Activity log: log simple de solo-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 adjuntas 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()
);
El campo stage_entered_at en deals es una de mis decisiones pequeñas favoritas. Cada vez que un deal se mueve a una nueva etapa, actualizamos este timestamp. Eso nos permite calcular "días en etapa actual" sin consultar el registro de actividad. Simple, rápido, útil.
El campo position maneja el ordenamiento dentro de columnas kanban. Cuando arrastras una tarjeta entre dos otras, calculamos un nuevo valor de posición. Usamos espaciado de enteros (las posiciones se incrementan en 1000) así que raramente necesitamos rebalancear.
Construyendo el Tablero Kanban
El tablero kanban es un componente React montado como una isla de Astro. Usamos @dnd-kit/core para drag and drop porque es la librería DnD más accesible y bien mantenida en el ecosistema de React a partir de 2026.
Aquí está la estructura 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);
// Suscribirse a cambios en tiempo 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>
);
}
La función moveDeal hace una actualización optimista — actualiza inmediatamente el estado local, luego envía la actualización a Supabase. Si la actualización de la base de datos falla, se revierte. Esto hace que el board se sienta instantáneo.
const moveDeal = async (dealId: string, newStage: string, newPosition: number) => {
// Actualización optimista
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) {
// Revertir — refetchar del servidor
await refreshDeals();
toast.error('Failed to move deal');
}
// Registrar la actividad
await supabase.from('activities').insert({
deal_id: dealId,
user_id: currentUser.id,
action: 'stage_change',
details: { from: previousStage, to: newStage },
});
};
La página de Astro que aloja esto es 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>
Esa directiva client:load está haciendo el trabajo pesado. El layout, nav, y shell de página son todos HTML renderizado del servidor. El tablero kanban en sí se hidrata en el cliente. Esto significa que la carga de página inicial es rápida — el navegador obtiene HTML inmediatamente, y el board interactivo arranca justo después.
Actualizaciones en Tiempo Real con Supabase Realtime
Esta fue la característica que hizo que Supabase fuera el ganador claro para este proyecto. Cuando un miembro del equipo mueve un deal, los otros miembros del equipo lo ven moverse en tiempo real. Sin necesidad de refrescar.
// 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);
};
}, []);
}
Una trampa: cuando TÚ mueves un deal, obtienes tu propio cambio de vuelta a través de la suscripción en tiempo real. Si no tienes cuidado, esto causa un glitch visual donde la tarjeta salta. Lo manejamos etiquetando actualizaciones optimistas con un timestamp e ignorando eventos en tiempo real que coinciden con cambios locales recientes. Son algunos líneas de código extra pero hace que la UX se sienta sólida.
Autenticación y Seguridad a Nivel de Fila
Ya que esta es una herramienta interna, la autenticación es simple. Usamos Supabase Auth con email/password. Tres cuentas. Sin flujo de sign-up — creamos las cuentas manualmente en el dashboard de Supabase.
Row-Level Security es donde se pone interesante. Aunque esta es una herramienta interna, RLS significa que nuestra base de datos no filtrará accidentalmente datos incluso si cometemos un error en el código de la aplicación.
-- Solo usuarios autenticados pueden ver deals
alter table deals enable row level security;
create policy "Authenticated users can read all deals"
on deals for select
to authenticated
using (true);
create policy "Authenticated users can insert deals"
on deals for insert
to authenticated
with check (true);
create policy "Authenticated users can update deals"
on deals for update
to authenticated
using (true);
create policy "Authenticated users can delete deals"
on deals for delete
to authenticated
using (true);
Sí, estas políticas son permisivas — cualquier usuario autenticado puede hacer cualquier cosa. Para un equipo de tres personas, está bien. Si alguna vez crecemos a un tamaño donde necesitemos permisos basados en roles, la infraestructura de RLS ya está ahí. Solo apretaríamos las políticas.
Despliegue y Costos de Hosting
Aquí viene la parte divertida. Hablemos de dinero.
| Servicio | Plan | Costo Mensual |
|---|---|---|
| Supabase | Free tier | $0 |
| Vercel (hosting Astro SSR) | Pro plan (ya lo tenía) | $0 incremental |
| Dominio | subdominio del dominio existente | $0 |
| Total | $0/mes |
Ya estábamos en el plan Pro de Vercel para proyectos de clientes, así que desplegar una app SSR más no nos cuesta nada extra. El free tier de Supabase nos da 500MB de almacenamiento de base de datos, 50,000 usuarios activos mensuales (tenemos 3), y conexiones en tiempo real. Estamos usando tal vez el 1% de la capacidad del free tier.
Compara eso con Monday.com:
| Monday.com | Nuestro CRM Personalizado | |
|---|---|---|
| Costo mensual | $48 (3 asientos, Pro) | $0 |
| Costo anual | $576 | $0 |
| Tiempo de build | 0 horas | ~20 horas |
| Mantenimiento | 0 horas/mes | ~1 hora/mes |
A nuestro tarifa interna por hora, 20 horas de tiempo de dev valen mucho más que $576/año. Pero esas matemáticas pierden el punto. Construimos esto parcialmente porque queríamos, parcialmente porque es una mejor herramienta para nuestro flujo de trabajo específico, y parcialmente porque esas 20 horas nos enseñaron cosas que hemos usado desde entonces en proyectos de clientes. Desde entonces hemos aplicado arquitecturas similares de Astro + Supabase para aplicaciones respaldadas por CMS headless que construimos para clientes.
Lo Que Haríamos Diferente
Ha pasado aproximadamente cuatro meses desde que lanzamos v1. Aquí está lo que cambiaría:
Usar Zustand Desde el Primer Día
Empezamos con el useState incorporado de React y useContext para gestión de estado. Para cuando agregamos sincronización en tiempo real, actualizaciones optimistas, y lógica de reversión, el código de gestión de estado estaba enredado. Migramos a Zustand después de dos semanas. Debería haber empezado ahí.
Añadir Búsqueda Antes
No construimos búsqueda hasta la semana tres, y esas tres semanas de escanear manualmente columnas por un deal específico fueron molestas. Una simple query ilike en Supabase habría tomado 30 minutos para implementar.
Atajos de Teclado
Aún no los hemos agregado, pero los queremos. Presiona N para crear un nuevo deal, / para buscar, 1-6 para filtrar por etapa. Pequeñas cosas que se suman cuando estás en la herramienta varias veces al día.
Mejor Vista Móvil
El tablero kanban funciona en móvil, técnicamente. Pero seis columnas no caben en una pantalla de teléfono. Necesitamos una vista de lista para móvil. No lo hemos priorizado porque raramente revisamos el CRM en nuestros teléfonos, pero sería agradable.
Preguntas Frecuentes
¿Cuánto tiempo tomó construir el tablero kanban del CRM? La primera versión usable tomó aproximadamente 20 horas repartidas en un fin de semana y algunas noches. Eso nos dio el tablero kanban, detalles de deal, drag and drop, y autenticación básica. Probablemente hemos pasado otras 10 horas desde entonces en mejoras como búsqueda, mejores estilos móviles, y correcciones de bugs.
¿Por Qué Astro en lugar de Next.js para una app dinámica? La arquitectura de islas de Astro significa que las partes no-interactivas de nuestra app (layout, nav, páginas estáticas) envían cero JavaScript. El tablero kanban en sí es una isla de React que se hidrata al cargar. Para una herramienta interna donde la superficie interactiva está enfocada en un componente, este es un gran ajuste. Usamos Next.js para proyectos de clientes donde la interactividad está más distribuida entre páginas.
¿Es el free tier de Supabase realmente suficiente para un CRM? Para un equipo pequeño, absolutamente. Tenemos tal vez 200 deals, 150 contactos, y algunos miles de entradas del registro de actividad. Eso son kilobytes de datos. El free tier de Supabase te da 500MB de almacenamiento, que no alcanzaremos durante años. El cap de conexiones en tiempo real también es generoso — obtienes hasta 200 conexiones concurrentes en el plan gratuito.
¿Qué hay sobre backups?
Supabase incluye backups diarios en el plan Pro ($25/mes), pero estamos en el free tier. Ejecutamos un pg_dump semanal a través de un trabajo cron en un VPS de $5/mes que ya teníamos. No es glamoroso, pero funciona. También tenemos un clon de proyecto de Supabase al que podemos restaurar si algo sale mal.
¿Puede este enfoque funcionar para un equipo más grande que 3 personas? Hasta tal vez 10-15 personas, creo que esto funcionaría bien con políticas de RLS más estrictas y algo de lógica de UI basada en roles. Más allá de eso, comenzarías queriendo características como automaciones, flujos de trabajo personalizados, y reportes que requerirían trabajo de ingeniería serio. En ese punto, una herramienta CRM dedicada tiene más sentido — solo tal vez no Monday.com.
¿Cómo se desempeña la sincronización en tiempo real? Supabase Realtime usa WebSockets bajo el capó, y para nuestro caso de uso (3 usuarios concurrentes, actualizaciones de baja frecuencia), es esencialmente instantáneo. Medimos la latencia end-to-end desde un usuario arrastrando una tarjeta hasta otro usuario viendo la actualización: típicamente 80-150ms. Eso es más rápido de lo que podemos percibir.
¿Consideraste alternativas CRM de código abierto como Twenty o Folk? Miramos Twenty (el CRM de código abierto que se lanzó en 2024) y es impresionante. Pero es un CRM completo con mucho más características de las que necesitábamos, y auto-alojarlo requiere más infraestructura. Nuestro objetivo era construir exactamente lo que necesitábamos y nada más. Si Twenty había existido cuando empezamos y hubiera tenido un modo kanban-enfocado más simple, habríamos podido ir por esa ruta.
¿Construirías herramientas internas personalizadas para clientes también? Lo hemos hecho, en realidad. Varios clientes han venido a nosotros después de superar herramientas como Monday.com, Notion, o Airtable para flujos de trabajo específicos. Típicamente construimos estos con Astro o Next.js en el frontend y Supabase o un CMS headless en el backend. Si parece algo que necesitas, deberíamos hablar.