Waarom We Monday.com Hebben Stopgezet en Onze CRM in Astro + Supabase Hebben Gebouwd
Je factuur komt binnen: $48/maand voor Monday.com. Drie gebruikers, Pro plan. En elke sprint review mompelt iemand dezelfde zin — "Ik haat dit ding." Niet omdat de software kapot is. Monday.com is echt indrukwekkend. Het doet alleen 200 dingen die je pijplijn niet nodig heeft en struikelt over de 4 workflows die je eigenlijk gebruikt. Kolommen sorteren niet op de manier waarop je verkoopproces werkt. Automatiseringen worden twee keer uitgevoerd. Het mobiele weergave maakt je accountmanager haar telefoon willen gooien. Dus hebben we gedaan wat elke dev shop met Supabase-tegoed en te veel meningen doet: we hebben het opnieuw gebouwd. Elf dagen, 1.200 regels Astro, nul spijt. Hier is het schema, de drag-controller, en de rekening waardoor onze CFO glimlachte.
Dit is geen "we zijn slimmer dan Monday.com" bericht. Het is een "we hadden zeer specifieke behoeften, een stack die we al kenden, en een weekend" bericht. Hier is het volledige verhaal van het bouwen van een aangepast CRM kanban board met Astro, Supabase, en een gezonde dosis verachting voor SaaS bloat.
Inhoudsopgave
- Waarom Monday.com Voor Ons Niet Meer Werkte
- Wat We Eigenlijk Nodig Hadden
- De Stack Kiezen: Astro + Supabase
- Databaseontwerp: Het Domweg Eenvoudig Houden
- Het Kanban Board Bouwen
- Real-Time Updates met Supabase Realtime
- Authenticatie en Row-Level Security
- Implementatie en Hosting Kosten
- Wat We Anders Zouden Doen
- Veelgestelde Vragen

Waarom Monday.com Voor Ons Niet Meer Werkte
Laat me specifiek zijn over onze frustraties, omdat vage klachten over SaaS tools nutteloos zijn.
Probleem 1: Het gegevensmodel vecht tegen ons. Monday.com denkt in "boards" en "items" met "kolommen." Onze bureauwerk denkt in deals, contacten en projecten — drie afzonderlijke entiteiten met relaties ertussen. We hadden een deal nodig die verwijst naar een contact, en een project dat verwijst naar een deal. Monday.com kan dit soort doen met gekoppelde kolommen, maar het is onhandig. Telkens wanneer iemand een nieuwe deal maakte, moest hij deze handmatig aan het juiste contact koppelen. Mensen vergaten het. Gegevens werden rommel.
Probleem 2: De kanban-weergave kon niet doen wat we wilden. We hadden deals nodig gegroepeerd op fase EN kleurgecodeerd op bron (verwijzing, organisch, outbound). Monday.com's kanban-weergave laat je groeperen op één statuskolom. Dat is het. Je kunt niet een tweede visuele dimensie toevoegen zonder het te hacken met naamgeving.
Probleem 3: Snelheid. Dit is subjectief, maar Monday.com voelde traag voor wat we deden. Klik op een deal, wacht tot het zijpaneel laadt, scroll voorbij velden die we niet gebruiken, vind de ene opmerking die we nodig hebben. Elke interactie had net genoeg latentie om wrijving te voelen.
Probleem 4: Kosttrajectorie. Op $48/maand voor drie personen is het niet duur. Maar we wilden een vierde teamlid, en Monday.com's prijzen springen naar $60/maand voor het Pro plan met 5 gebruikers (je kunt niet 4 kopen). Dat is $720/jaar voor een tool waarvan we actief klaagden.
Het Kantelpunt
De werkelijke trigger was beschamend alledaags. Een potentiële klant mailde ons, en twee teamleden antwoordden beiden omdat geen van beiden kon zien van Monday.com wie de lead had "opgeëist". Het notificatiesysteem maakte het niet duidelijk genoeg op het oppervlak, en onze hacky oplossing om jezelf toe te voegen aan een "People" kolom was niet betrouwbaar. Dat is toen ik VS Code opende in plaats van Monday.com.
Wat We Eigenlijk Nodig Hadden
Voordat we code schreven, besteedden we ongeveer een uur aan het oplijsten van precies wat onze CRM moest doen. Niet wat leuk zou zijn. Wat eigenlijk nodig was.
Hier is de lijst:
- Kanban board met kolommen voor deal fasen: Lead → Gecontacteerd → Voorstel → Onderhandelingen → Gewonnen → Verloren
- Deal kaarten met: contactnaam, dealwaarde, brontag (kleurgecodeerd), toegewezen teamlid, dagen in huidige fase
- Slepen en neerzetten tussen kolommen met direct behoud
- Deal detailweergave met notities (markdown), contactgegevens, en een eenvoudig activiteitenlogboek
- Real-time synchronisatie zodat twee personen die het bord bekijken dezelfde staat zien
- Contactdatabase met basisinfo (naam, e-mail, bedrijf, notities)
- Eenvoudige auth — alleen ons team, geen openbare toegang
Dat is alles. Geen Gantt-diagrammen. Geen tijdregistratie. Geen automatiseringsmotor. Geen 47 verschillende kolomtypen. Alleen een kanban board ondersteund door een echte database met echte relaties.
De Stack Kiezen: Astro + Supabase
We zijn een Astro-ontwikkelingsbedrijf, dus Astro was de voor de hand liggende startpunt. Maar het is de moeite waard uit te leggen waarom het hier eigenlijk zinvol is, omdat Astro's reputatie als "static site generator" het aanzienlijk te kort doet.
Sinds Astro 4.x (en nu 5.x in 2026) is server-side rendering met on-demand routes een first-class feature. Je kunt volledige dynamische applicaties bouwen. We gebruiken Astro's hybrid rendering mode: de meeste pagina's worden server-rendered on request, maar we kunnen dingen als de login pagina nog steeds pre-renderen.
Voor het interactieve kanban board zelf gebruiken we een React island. Dit is Astro's killer feature voor apps zoals deze — de shell van de applicatie (nav, layout, auth checks) is server-rendered met nul JS, en het kanban board monteert als een enkele interactieve island met client:load.
Supabase was de databasekeuze om verschillende redenen:
| Feature | Waarom Het Ertoe Deed |
|---|---|
| Postgres onder de motorkap | Echte relationele database, echte foreign keys, echte queries |
| Realtime subscriptions | Ingebouwde WebSocket ondersteuning voor live updates |
| Row-Level Security (RLS) | Auth regels op databaseniveau, niet alleen op app-niveau |
| JS client library | Schone API, goede TypeScript ondersteuning |
| Gratis tier | Ons gebruik past comfortabel in Supabase's gratis plan |
| Self-host optie | Als we ooit het gratis tier uitgroeien, kunnen we het zelf hosten |
We hebben kort andere opties overwogen:
| Optie | Waarom We Het Skippten |
|---|---|
| Firebase / Firestore | NoSQL maakt relationele gegevens awkward. Eerder verbrand. |
| PlanetScale | Geweldig, maar geen ingebouwde realtime. Zou aparte WebSocket oplossing nodig hebben. |
| Neon + Prisma | Solide combo, maar Supabase geeft ons auth + realtime + DB in één. |
| Bouwen op Next.js | We kennen Next.js goed, maar voor een intern tool betekent Astro's island architectuur minder client-side JS voor de niet-interactieve delen. |

Databaseontwerp: Het Domweg Eenvoudig Houden
Het schema heeft vier tabellen. Dat is alles.
-- Contacts: the people and companies we talk to
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: the pipeline items on our 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, -- stored in cents
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, -- for ordering within a column
stage_entered_at timestamptz default now(),
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Activity log: simple 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: markdown notes attached to 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()
);
Het stage_entered_at veld op deals is één van mijn favoriete kleine beslissingen. Telkens wanneer een deal naar een nieuw stadium gaat, werken we deze timestamp bij. Dat laat ons "dagen in huidige stadium" berekenen zonder het activiteitenlogboek te queryren. Eenvoudig, snel, nuttig.
Het position veld handelt ordening binnen kanban kolommen af. Wanneer je een kaart tussen twee anderen sleept, berekenen we een nieuwe positiewaarde. We gebruiken integer spacing (posities verhogen met 1000) dus we hoeven zelden opnieuw in evenwicht te brengen.
Het Kanban Board Bouwen
Het kanban board is een React component gemonteerd als een Astro island. We gebruikten @dnd-kit/core voor slepen en neerzetten omdat het de meest toegankelijke en goed onderhouden DnD bibliotheek in het React ecosysteem is vanaf 2026.
Hier is de vereenvoudigde structuur:
// 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>
);
}
De moveDeal functie doet een optimistische update — het werkt onmiddellijk lokale state bij, en stuurt de update vervolgens naar Supabase. Als de databaseupdate mislukt, wordt deze teruggedraaid. Dit maakt het board voelen als onmiddellijk.
const moveDeal = async (dealId: string, newStage: string, newPosition: number) => {
// Optimistic 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 from server
await refreshDeals();
toast.error('Failed to move deal');
}
// Log the activity
await supabase.from('activities').insert({
deal_id: dealId,
user_id: currentUser.id,
action: 'stage_change',
details: { from: previousStage, to: newStage },
});
};
De Astro pagina die dit host is minimaal:
---
// 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>
Die client:load richtlijn doet het zware werk. De layout, nav, en paginashell zijn allemaal server-rendered HTML. Het kanban board zelf hydreert op de client. Dit betekent dat de initiële pagina laad snel is — de browser krijgt onmiddellijk HTML en het interactieve board start meteen daarna op.
Real-Time Updates met Supabase Realtime
Dit was de functie die Supabase de duidelijke winnaar maakte voor dit project. Wanneer één teamlid een deal verplaatst, zien de andere teamleden het onmiddellijk verplaatsen. Geen vernieuwen nodig.
// 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);
};
}, []);
}
Eén gotcha: wanneer JIJ een deal verplaatst, krijg je je eigen wijziging terug via de realtime subscription. Als je niet voorzichtig bent, veroorzaakt dit een visuele glitch waar de kaart springt. We hanteren dit door optimistische updates te labelen met een timestamp en realtime events te negeren die recente lokale wijzigingen matchen. Het zijn een paar extra regels code maar het maakt de UX voelen echt.
Authenticatie en Row-Level Security
Omdat dit een intern tool is, is auth eenvoudig. We gebruiken Supabase Auth met e-mail/wachtwoord. Drie accounts. Geen sign-up flow — we maakten de accounts handmatig in het Supabase dashboard.
Row-Level Security is waar het interessant wordt. Hoewel dit een intern tool is, betekent RLS dat onze database niet onbedoeld gegevens zal lekken zelfs als we de applicatiecode verprullen.
-- Only authenticated users can see 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);
Ja, deze policies zijn permissief — elke geverifieerde gebruiker kan alles doen. Voor een team van drie personen is dat prima. Als we ooit groeien tot een grootte waar we rol-gebaseerde permissies nodig hebben, is de RLS-infrastructuur al aanwezig. We zouden gewoon de policies aanscherpen.
Implementatie en Hosting Kosten
Hier komt het grappige gedeelte. Laten we over geld praten.
| Service | Plan | Maandelijkse Kosten |
|---|---|---|
| Supabase | Gratis tier | $0 |
| Vercel (hosting Astro SSR) | Pro plan (hadden we al) | $0 incrementeel |
| Domain | subdomein van bestaand domein | $0 |
| Totaal | $0/maand |
We zaten al op Vercel's Pro plan voor client projecten, dus het implementeren van één meer SSR app kost ons niets extra. Supabase's gratis tier geeft ons 500MB databaseopslag, 50.000 maandelijks actieve gebruikers (we hebben 3), en realtime verbindingen. We gebruiken misschien 1% van de gratis tier's capaciteit.
Vergelijken met Monday.com:
| Monday.com | Onze Aangepaste CRM | |
|---|---|---|
| Maandelijkse kosten | $48 (3 gebruikers, Pro) | $0 |
| Jaarlijkse kosten | $576 | $0 |
| Bouwstijd | 0 uur | ~20 uur |
| Onderhoud | 0 uur/maand | ~1 uur/maand |
Bij onze interne uurtarief is 20 uur dev-tijd veel meer waard dan $576/jaar. Maar die wiskunde mist het punt. We bouwden dit deels omdat we wilden, deels omdat het een beter gereedschap voor onze specifieke workflow is, en deels omdat die 20 uur ons dingen leerden die we sindsdien hebben gebruikt op client projecten. We hebben sindsdien vergelijkbare Astro + Supabase architecturen toegepast voor headless CMS-ondersteunde applicaties die we voor klanten bouwen.
Wat We Anders Zouden Doen
Het is ongeveer vier maanden geleden dat we v1 launchten. Hier is wat ik zou veranderen:
Zustand Van Dag Één Gebruiken
We begonnen met React's ingebouwde useState en useContext voor state management. Tegen de tijd dat we realtime sync, optimistische updates, en rollback logica toevoegden, was de state management code verward. We migreerden naar Zustand na twee weken. Had daar moeten beginnen.
Zoeken Eerder Toevoegen
We bouwden zoeken niet tot week drie, en die drie weken van handmatig scannen van kolommen voor een specifieke deal waren vervelend. Een eenvoudige ilike query op Supabase zou 30 minuten hebben genomen om in te implementeren.
Keyboard Shortcuts
Echt nog niet toegevoegd, maar we willen ze. Druk op N om een nieuwe deal te maken, / om te zoeken, 1-6 om op fase te filteren. Kleine dingen die optellen wanneer je meerdere keren per dag in het gereedschap bent.
Beter Mobiel Beeld
Het kanban board werkt op mobiel, technisch gezien. Maar zes kolommen passen niet op een telefoonscherm. We hebben een lijstweergave voor mobiel nodig. Hebben het niet geprioriteerd omdat we zelden de CRM op onze telefoons controleren, maar het zou leuk zijn.
Veelgestelde Vragen
Hoe lang duurde het om het CRM kanban board te bouwen? De eerste bruikbare versie duurde ongeveer 20 uur verspreid over een weekend en een paar avonden. Dat gaf ons het kanban board, deal details, slepen en neerzetten, en basis auth. We hebben waarschijnlijk nog 10 uur sinds dien besteed aan verbeteringen zoals zoeken, betere mobiele stijlen, en bugfixes.
Waarom Astro in plaats van Next.js voor een dynamische app? Astro's island architectuur betekent dat de niet-interactieve delen van onze app (layout, nav, statische pagina's) nul JavaScript verzenden. Het kanban board zelf is een React island die op laad hydreert. Voor een intern gereedschap waar het interactieve oppervlak is gericht op één component, is dit een geweldige fit.
Is Supabase's gratis tier echt genoeg voor een CRM? Voor een klein team, absoluut. We hebben misschien 200 deals, 150 contacten, en een paar duizend activiteitenlogboekitems. Dat zijn kilobytes gegevens. Supabase's gratis tier geeft je 500MB opslag, wat we jaren niet zullen bereiken. De realtime verbindingslimiet is ook royaal — je krijgt tot 200 gelijktijdige verbindingen op het gratis plan.
Wat met backups?
Supabase bevat dagelijkse backups op het Pro plan ($25/maand), maar we zitten op de gratis tier. We draaien een wekelijkse pg_dump via een cron job op een $5/maand VPS die we al hadden. Het is niet glamoureus, maar het werkt. We hebben ook een Supabase project clone die we kunnen herstellen als iets fout gaat.
Kan deze benadering voor een team groter dan 3 personen werken? Tot misschien 10-15 personen, denk ik dat dit goed zou werken met sterkere RLS policies en wat op rol gebaseerde UI logica. Voorbij dat zou je features willen zoals automatiseringen, aangepaste workflows, en rapportage die serieuze engineering-inspanning zou vergen. Op dat moment maakt een specifiek CRM gereedschap meer zin — alleen misschien niet Monday.com.
Hoe presteert de real-time sync? Supabase Realtime gebruikt WebSockets onder de motorkap, en voor ons geval (3 gelijktijdige gebruikers, lage-frequentie updates) is het vrijwel onmiddellijk. We maten de end-to-end latentie van één gebruiker die een kaart sleept naar een ander gebruiker die de update ziet: typisch 80-150ms. Dat is sneller dan we kunnen waarnemen.
Heb je open-source CRM alternatieven zoals Twenty of Folk overwogen? We keken naar Twenty (de open-source CRM die in 2024 lanceerde) en het is indrukwekkend. Maar het is een volledige CRM met veel meer functies dan we nodig hadden, en het zelf hosten vereist meer infrastructuur. Ons doel was precies te bouwen wat we nodig hadden en niets meer. Als Twenty had bestaan toen we begonnen en had een eenvoudiger kanban-gericht mode gehad, hebben we dat misschien geprobeerd.
Zou je aangepaste intern gereedschap voor klanten bouwen? We hebben het gedaan, eigenlijk. Verschillende klanten zijn naar ons gekomen na het uitgroeien van gereedschappen zoals Monday.com, Notion, of Airtable voor specifieke workflows. We bouwen deze typisch met Astro of Next.js op de frontend en Supabase of een headless CMS op de backend. Als dat klinkt als iets dat je nodig hebt, zouden we moeten praten.