We betaalden $48/maand voor Monday.com. Drie seats, Pro plan. En elke week zei iemand op het team iets in de trant van "Ik haat dit." Niet omdat Monday.com slecht is — het is echt indrukwekkende software. Maar het deed ongeveer 200 dingen die we niet nodig hadden en kon de 4 dingen die we echt wilden niet goed doen. Dus deden we wat elk bureau vol developers met te veel meningen zou doen: we bouwden onze eigen.

Dit is geen "we zijn slimmer dan Monday.com"-post. Het is een "we hadden heel specifieke behoeften, een stack die we al kenden, en een weekend"-post. Hier is het volledige verhaal van het bouwen van een custom CRM kanban board met Astro, Supabase, en een gezonde dosis minachting voor SaaS-bloat.

Inhoudsopgave

Inside Our CRM Kanban: Why We Rebuilt Monday.com in Astro + Supabase

Waarom Monday.com niet meer voor ons werkte

Laat me specifiek zijn over onze frustraties, want vage klachten over SaaS-tools zijn nutteloos.

Probleem 1: Het datamodel werkte tegen ons. Monday.com denkt in "boards" en "items" met "columns." Ons bureau denkt in deals, contacten en projecten — drie verschillende entiteiten met relaties tussen hen. 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. Elke keer dat iemand een nieuwe deal maakte, moest deze handmatig aan het juiste contact worden gekoppeld. Mensen vergaten dat. Data werd rommelig.

Probleem 2: Het kanban-view kon niet doen wat we wilden. We hadden deals nodig gegroepeerd op stage EN kleurgecodeerd op bron (referral, organic, outbound). Het kanban-view van Monday.com laat je groeperen op één statuskolom. Dat is alles. Je kunt geen tweede visuele dimensie toevoegen zonder te hacken met naamconventies.

Probleem 3: Snelheid. Dit is subjectief, maar Monday.com voelde langzaam voor wat we deden. Klik op een deal, wacht tot het zijpaneel laadt, scroll voorbij velden die we niet gebruiken, vind de ene notitie die we nodig hebben. Elke interactie had net genoeg latency om zich voelt als wrijving.

Probleem 4: Kosttrajectorie. Bij $48/maand voor drie personen is het niet duur. Maar we dachten aan een vierde teamlid, en de prijsstelling van Monday.com springt naar $60/maand voor het Pro plan met 5 seats (je kunt niet 4 kopen). Dat is $720/jaar voor een tool waar we actief over klaagden.

Het kantelpunt

De eigenlijke trigger was beschamend banaal. Een potentiële klant mailde ons, en twee teamleden antwoordden allebei omdat geen van beiden kon zien in Monday.com wie de lead had "geclaimd". Het notificatiesysteem surfde het niet duidelijk genoeg op, en onze hacky workaround van jezelf toevoegen aan een "People"-kolom was niet betrouwbaar. Dat was het moment dat ik VS Code opende in plaats van Monday.com.

Wat we echt nodig hadden

Voordat we code schreven, besteedden we ongeveer een uur aan het exact bepalen wat onze CRM moest doen. Niet wat prettig zou zijn. Wat echt noodzakelijk was.

Hier is de lijst:

  1. Kanban board met kolommen voor deal-stages: Lead → Contacted → Proposal → Negotiation → Won → Lost
  2. Deal cards met: contactnaam, deal-waarde, source tag (kleurgecodeerd), toegewezen teamlid, dagen in huidige stage
  3. Sleep en drop tussen kolommen met onmiddellijke persistentie
  4. Deal detail view met notities (markdown), contactinfo en een eenvoudig activiteitenlogboek
  5. Real-time sync zodat twee personen die naar het board kijken dezelfde staat zien
  6. Contactdatabase met basisinfo (naam, e-mail, bedrijf, notities)
  7. Eenvoudige auth — alleen ons team, geen publieke toegang

Dat is alles. Geen Gantt-diagrammen. Geen tijdregistratie. Geen automationsengine. Geen 47 verschillende kolomtypen. Alleen een kanban board ondersteund door een echte database met echte relaties.

Stack kiezen: Astro + Supabase

We zijn een Astro development shop, dus Astro was de voor de hand liggende startpunt. Maar het is de moeite waard om uit te leggen waarom het eigenlijk hier logisch is, omdat Astro's reputatie als "statische site generator" het aanzienlijk onderschat.

Sinds Astro 4.x (en nu 5.x in 2025) is server-side rendering met on-demand routes een eersteklas feature. Je kunt volledige dynamische applicaties bouwen. We gebruiken Astro's hybride render mode: de meeste pagina's worden server-rendered op aanvraag, maar we kunnen dingen zoals de loginpagina nog steeds voorrenderen.

Voor het interactieve kanban board zelf gebruiken we een React island. Dit is Astro's killer feature voor apps als deze — de shell van de applicatie (nav, layout, auth checks) wordt server-rendered met nul JS, en het kanban board wordt gemount als een enkele interactieve island met client:load.

Supabase was de databasekeuze om verschillende redenen:

Feature Waarom het belangrijk was
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 app-niveau
JS client library Schone API, goede TypeScript-ondersteuning
Free tier Ons gebruik past comfortabel in Supabase's gratis plan
Self-host optie Als we ooit buiten de gratis tier groeien, kunnen we het zelf draaien

We hebben kort andere opties overwogen:

Optie Waarom we niet kozen
Firebase / Firestore NoSQL maakt relationele data onhandig. Eerder gebrand.
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 (we bouwen er regelmatig mee), maar voor een intern tool betekende Astro's island-architectuur minder client-side JS voor de niet-interactieve onderdelen.

Inside Our CRM Kanban: Why We Rebuilt Monday.com in Astro + Supabase - architecture

Databaseontwerp: Simpel houden

Het schema heeft vier tabellen. Dat is alles.

-- Contacts: de mensen en bedrijven waar we mee praten
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: de pipeline-items op ons 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, -- opgeslagen 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, -- voor ordening binnen een kolom
  stage_entered_at timestamptz default now(),
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Activity log: eenvoudig 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', enz.
  details jsonb,
  created_at timestamptz default now()
);

-- Deal notes: markdown-notities gekoppeld aan 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 een van mijn favoriete kleine beslissingen. Elke keer dat een deal naar een nieuwe stage gaat, updaten we deze timestamp. Hiermee kunnen we "dagen in huidige stage" berekenen zonder het activiteitenlogboek te raadplegen. Eenvoudig, snel, nuttig.

Het position-veld regelt ordening binnen kanban-kolommen. Wanneer je een kaart tussen twee andere sleept, berekenen we een nieuwe positiewaarde. We gebruiken integer-spacing (posities verhogen met 1000) zodat we zelden moeten herbalanceren.

Het Kanban Board bouwen

Het kanban board is een React-component gemount als een Astro island. We gebruikten @dnd-kit/core voor drag en drop omdat het de meest toegankelijke en goed onderhouden DnD-bibliotheek in het React-ecosysteem is vanaf 2025.

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

  // Abonneer je op realtime-wijzigingen
  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 updatet onmiddellijk de lokale state, dan stuurt het de update naar Supabase. Als de database-update mislukt, rolt het terug. Dit maakt het board voelen als instant.

const moveDeal = async (dealId: string, newStage: string, newPosition: number) => {
  // Optimistische 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) {
    // Terugdraaien — opnieuw laden van server
    await refreshDeals();
    toast.error('Failed to move deal');
  }

  // Log de activiteit
  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-directive doet het zware werk. De layout, nav en pagina-shell zijn allemaal server-rendered HTML. Het kanban board zelf hydateert op de client. Dit betekent dat het eerste pagina-laad snel is — de browser krijgt HTML onmiddellijk, en het interactieve board start meteen daarna op.

Real-time updates met Supabase Realtime

Dit was de feature die Supabase de duidelijke winnaar maakte voor dit project. Wanneer één teamlid een deal verplaatst, zien de andere teamleden het in real-time. Geen refresh 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-abonnement. Als je niet voorzichtig bent, veroorzaakt dit een visuele glitch waar de kaart springt. We handelen dit af door optimistische updates te taggen met een timestamp en realtime-events te negeren die overeenkomen met recente lokale wijzigingen. Het zijn een paar extra regels code, maar het maakt de UX solide voelen.

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 per ongeluk data lekt, zelfs als we de applicatiecode verpesten.

-- Alleen geverifieerde gebruikers kunnen deals zien
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 drie-persoonsteam is dat prima. Als we ooit groeien tot een grootte waarbij we op rollen gebaseerde rechten nodig hebben, is de RLS-infrastructuur al klaar. We zouden alleen de policies aanscherpen.

Deployment en hostingkosten

Hier is het grappige gedeelte. Laten we over geld praten.

Service Plan Maandelijkse kosten
Supabase Free 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 clientprojecten, dus één extra SSR-app deployen kost ons niks extra. Supabase's gratis tier geeft je 500MB databaseopslag, 50.000 maandelijkse actieve gebruikers (we hebben 3), en realtime-verbindingen. We gebruiken misschien 1% van de capaciteit van de gratis tier.

Vergelijk dat met Monday.com:

Monday.com Onze custom CRM
Maandelijkse kosten $48 (3 seats, Pro) $0
Jaarlijkse kosten $576 $0
Bouwijd 0 uur ~20 uur
Onderhoud 0 uur/maand ~1 uur/maand

Op ons interne uurtarief is 20 uur dev-tijd veel meer waard dan $576/jaar. Maar die rekenkunde mist het punt. We bouwden dit deels omdat we wilden, deels omdat het een beter tool is voor onze specifieke workflow, en deels omdat die 20 uur ons dingen hebben geleerd die we sindsdien op klantprojecten hebben gebruikt. We hebben sindsdien vergelijkbare Astro + Supabase-architecturen toegepast op headless CMS-ondersteunde applicaties die we voor klanten bouwen.

Wat we anders zouden doen

Het is nu ongeveer vier maanden sinds we v1 hebben uitgebracht. Hier is wat ik zou veranderen:

Gebruik Zustand vanaf dag één

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 in elkaar verstrengeld. We migreerden naar Zustand na twee weken. Hadden daar vanaf het begin mee moeten beginnen.

Voeg eerder zoeken toe

We hebben zoeken niet gebouwd tot week drie, en die drie weken van handmatig kolommen scannen voor een specifieke deal waren vervelend. Een eenvoudige ilike-query op Supabase zou 30 minuten hebben geduurd om te implementeren.

Sneltoetsen

Hebben we nog niet toegevoegd, maar we willen ze. Druk N om een nieuwe deal te maken, / om te zoeken, 1-6 om op stage te filteren. Kleine dingen die optellen wanneer je meerdere keren per dag in het tool bent.

Beter mobiel view

Het kanban board werkt op mobiel, technisch gezien. Maar zes kolommen passen niet op een telefoonscherm. We hebben een lijstweergave nodig voor mobiel. Hebben het niet prioriteit gegeven 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, sleep en drop, en basisauth. We hebben waarschijnlijk nog 10 uur sinds dan 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 onderdelen van onze app (layout, nav, statische pagina's) nul JavaScript verzenden. Het kanban board zelf is een React island die op load hydateert. Voor een intern tool waarbij het interactieve oppervlak gericht is op één component, is dit een geweldig geschikt. We gebruiken Next.js voor klantprojecten waarbij de interactiviteit meer verdeeld is over pagina's.

Is Supabase's gratis tier echt voldoende voor een CRM? Voor een klein team, absoluut. We hebben misschien 200 deals, 150 contacten, en een paar duizend activiteitenlog-items. Dat zijn kilobytes aan data. Supabase's gratis tier geeft je 500MB opslag, wat we jaren niet zullen halen. De realtime-verbindingslimiet is ook genereus — je krijgt tot 200 gelijktijdige verbindingen op het gratis plan.

Wat doen we met backups? Supabase bevat dagelijkse backups op het Pro plan ($25/maand), maar we zijn op de gratis tier. We voeren wekelijks pg_dump uit via een cron job op een VPS van $5/maand die we al hadden. Het is niet elegant, maar het werkt. We hebben ook een Supabase-projectkloon waarvan we kunnen herstellen als er iets misgaat.

Kan deze aanpak voor een team groter dan 3 personen werken? Tot misschien 10-15 personen, denk ik dat dit goed zou werken met strictere RLS-policies en wat op rollen gebaseerde UI-logica. Daarboven zou je features gaan willen zoals automaties, aangepaste workflows en rapportage die serieuze engineeringinspanning zouden vergen. Op dat moment heeft een dedicated CRM-tool meer zin — alleen misschien niet Monday.com.

Hoe presteert de real-time sync? Supabase Realtime gebruikt WebSockets onder de motorkap, en voor ons use case (3 gelijktijdige gebruikers, lage-frequentie updates) is het vrijwel instant. 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.

Hebben jullie 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 features dan we nodig hadden, en self-hosting ervan vereist meer infrastructuur. Ons doel was om precies wat we nodig hadden en niks meer te bouwen. Als Twenty zou hebben bestaan toen we begonnen en een eenvoudigere kanban-gefocuste modus had gehad, zouden we die route waarschijnlijk hebben genomen.

Zouden jullie aangepaste interne tools voor klanten bouwen? We hebben dat al gedaan, eigenlijk. Verschillende klanten zijn naar ons gekomen nadat ze tools als Monday.com, Notion of Airtable waren ontgroeid voor specifieke workflows. We bouwen deze doorgaans met Astro of Next.js op de frontend en Supabase of een headless CMS op de backend. Als dat iets is wat je nodig hebt, we moeten praten.