We were paying $48/month for Monday.com. Three seats, Pro plan. And every single week, someone on the team would say some version of "I hate this." Not because Monday.com is bad — it's genuinely impressive software. But it was doing about 200 things we didn't need and couldn't quite do the 4 things we actually wanted. So we did what any agency full of developers with too many opinions would do: we built our own.

This isn't a "we're smarter than Monday.com" post. It's a "we had very specific needs, a stack we already knew, and a weekend" post. Here's the full story of building a custom CRM kanban board using Astro, Supabase, and a healthy dose of spite toward SaaS bloat.

Table of Contents

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

Why Monday.com Stopped Working for Us

Let me be specific about our frustrations, because vague complaints about SaaS tools are useless.

Problem 1: The data model was fighting us. Monday.com thinks in "boards" and "items" with "columns." Our agency thinks in deals, contacts, and projects — three distinct entities with relationships between them. We needed a deal to reference a contact, and a project to reference a deal. Monday.com can sort of do this with linked columns, but it's clunky. Every time someone created a new deal, they had to manually link it to the right contact. People forgot. Data got messy.

Problem 2: The kanban view couldn't do what we wanted. We needed to see deals grouped by stage AND color-coded by source (referral, organic, outbound). Monday.com's kanban view lets you group by one status column. That's it. You can't layer on a second visual dimension without hacking it with naming conventions.

Problem 3: Speed. This one's subjective, but Monday.com felt slow for what we were doing. Click a deal, wait for the side panel to load, scroll past fields we don't use, find the one note we need. Every interaction had just enough latency to feel like friction.

Problem 4: Cost trajectory. At $48/month for three people, it's not expensive. But we were eyeing a fourth team member, and Monday.com's pricing jumps to $60/month for the Pro plan with 5 seats (you can't buy 4). That's $720/year for a tool we were actively complaining about.

The Tipping Point

The actual trigger was embarrassingly mundane. A potential client emailed us, and two team members both replied because neither could tell from Monday.com who had "claimed" the lead. The notification system didn't surface it clearly enough, and our hacky workaround of adding yourself to a "People" column wasn't reliable. That's when I opened VS Code instead of Monday.com.

What We Actually Needed

Before writing any code, we spent about an hour listing exactly what our CRM needed to do. Not what would be nice. What was actually necessary.

Here's the list:

  1. Kanban board with columns for deal stages: Lead → Contacted → Proposal → Negotiation → Won → Lost
  2. Deal cards showing: contact name, deal value, source tag (color-coded), assigned team member, days in current stage
  3. Drag and drop between columns with instant persistence
  4. Deal detail view with notes (markdown), contact info, and a simple activity log
  5. Real-time sync so two people looking at the board see the same state
  6. Contact database with basic info (name, email, company, notes)
  7. Simple auth — just our team, no public access

That's it. No Gantt charts. No time tracking. No automations engine. No 47 different column types. Just a kanban board backed by a real database with real relationships.

Choosing the Stack: Astro + Supabase

We're an Astro development shop, so Astro was the obvious starting point. But it's worth explaining why it actually makes sense here, because Astro's reputation as a "static site generator" undersells it significantly.

Since Astro 4.x (and now 5.x in 2025), server-side rendering with on-demand routes is a first-class feature. You can build full dynamic applications. We use Astro's hybrid rendering mode: most pages are server-rendered on request, but we can still pre-render things like the login page.

For the interactive kanban board itself, we use a React island. This is Astro's killer feature for apps like this — the shell of the application (nav, layout, auth checks) is server-rendered with zero JS, and the kanban board mounts as a single interactive island with client:load.

Supabase was the database choice for several reasons:

Feature Why It Mattered
Postgres under the hood Real relational database, real foreign keys, real queries
Realtime subscriptions Built-in WebSocket support for live updates
Row-Level Security (RLS) Auth rules at the database level, not just the app level
JS client library Clean API, good TypeScript support
Free tier Our usage fits comfortably in Supabase's free plan
Self-host option If we ever outgrow the free tier, we can run it ourselves

We briefly considered other options:

Option Why We Passed
Firebase / Firestore NoSQL makes relational data awkward. Been burned before.
PlanetScale Great, but no built-in realtime. Would need separate WebSocket solution.
Neon + Prisma Solid combo, but Supabase gives us auth + realtime + DB in one.
Building on Next.js We know Next.js well (we build with it regularly), but for an internal tool, Astro's island architecture meant less client-side JS for the non-interactive parts.

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

Database Design: Keeping It Stupid Simple

The schema has four tables. That's it.

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

The stage_entered_at field on deals is one of my favorite small decisions. Every time a deal moves to a new stage, we update this timestamp. That lets us calculate "days in current stage" without querying the activity log. Simple, fast, useful.

The position field handles ordering within kanban columns. When you drag a card between two others, we calculate a new position value. We use integer spacing (positions increment by 1000) so we rarely need to rebalance.

Building the Kanban Board

The kanban board is a React component mounted as an Astro island. We used @dnd-kit/core for drag and drop because it's the most accessible and well-maintained DnD library in the React ecosystem as of 2025.

Here's the simplified structure:

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

The moveDeal function does an optimistic update — it immediately updates local state, then sends the update to Supabase. If the database update fails, it rolls back. This makes the board feel instant.

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

The Astro page that hosts this is minimal:

---
// 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>

That client:load directive is doing the heavy lifting. The layout, nav, and page shell are all server-rendered HTML. The kanban board itself hydrates on the client. This means the initial page load is fast — the browser gets HTML immediately, and the interactive board boots up right after.

Real-Time Updates with Supabase Realtime

This was the feature that made Supabase the clear winner for this project. When one team member moves a deal, the other team members see it move in real time. No refresh needed.

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

One gotcha: when YOU move a deal, you get your own change back via the realtime subscription. If you're not careful, this causes a visual glitch where the card jumps. We handle this by tagging optimistic updates with a timestamp and ignoring realtime events that match recent local changes. It's a few extra lines of code but it makes the UX feel solid.

Authentication and Row-Level Security

Since this is an internal tool, auth is simple. We use Supabase Auth with email/password. Three accounts. No sign-up flow — we created the accounts manually in the Supabase dashboard.

Row-Level Security is where it gets interesting. Even though this is an internal tool, RLS means our database won't accidentally leak data even if we screw up the application code.

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

Yeah, these policies are permissive — any authenticated user can do anything. For a three-person team, that's fine. If we ever grow to a size where we need role-based permissions, the RLS infrastructure is already there. We'd just tighten the policies.

Deployment and Hosting Costs

Here's the fun part. Let's talk money.

Service Plan Monthly Cost
Supabase Free tier $0
Vercel (hosting Astro SSR) Pro plan (already had it) $0 incremental
Domain subdomain of existing domain $0
Total $0/month

We were already on Vercel's Pro plan for client projects, so deploying one more SSR app costs us nothing extra. Supabase's free tier gives us 500MB of database storage, 50,000 monthly active users (we have 3), and realtime connections. We're using maybe 1% of the free tier's capacity.

Compare that to Monday.com:

Monday.com Our Custom CRM
Monthly cost $48 (3 seats, Pro) $0
Annual cost $576 $0
Build time 0 hours ~20 hours
Maintenance 0 hours/month ~1 hour/month

At our internal hourly rate, 20 hours of dev time is worth a lot more than $576/year. But that math misses the point. We built this partly because we wanted to, partly because it's a better tool for our specific workflow, and partly because those 20 hours taught us things we've since used on client projects. We've since applied similar Astro + Supabase architectures for headless CMS-backed applications we build for clients.

What We'd Do Differently

It's been about four months since we shipped v1. Here's what I'd change:

Use Zustand from Day One

We started with React's built-in useState and useContext for state management. By the time we added realtime sync, optimistic updates, and rollback logic, the state management code was tangled. We migrated to Zustand after two weeks. Should've started there.

Add Search Earlier

We didn't build search until week three, and those three weeks of manually scanning columns for a specific deal were annoying. A simple ilike query on Supabase would've taken 30 minutes to implement.

Keyboard Shortcuts

Still haven't added these, but we want them. Press N to create a new deal, / to search, 1-6 to filter by stage. Small things that add up when you're in the tool multiple times a day.

Better Mobile View

The kanban board works on mobile, technically. But six columns don't fit on a phone screen. We need a list view for mobile. Haven't prioritized it because we rarely check the CRM on our phones, but it'd be nice.

FAQ

How long did it take to build the CRM kanban board? The first usable version took about 20 hours spread across a weekend and a few evenings. That got us the kanban board, deal details, drag and drop, and basic auth. We've probably spent another 10 hours since then on improvements like search, better mobile styles, and bug fixes.

Why Astro instead of Next.js for a dynamic app? Astro's island architecture means the non-interactive parts of our app (layout, nav, static pages) ship zero JavaScript. The kanban board itself is a React island that hydrates on load. For an internal tool where the interactive surface area is focused on one component, this is a great fit. We use Next.js for client projects where the interactivity is more distributed across pages.

Is Supabase's free tier really enough for a CRM? For a small team, absolutely. We have maybe 200 deals, 150 contacts, and a few thousand activity log entries. That's kilobytes of data. Supabase's free tier gives you 500MB of storage, which we won't hit for years. The realtime connections cap is generous too — you get up to 200 concurrent connections on the free plan.

What about backups? Supabase includes daily backups on the Pro plan ($25/month), but we're on the free tier. We run a weekly pg_dump via a cron job on a $5/month VPS we already had. It's not glamorous, but it works. We also have a Supabase project clone we can restore to if anything goes wrong.

Can this approach work for a team larger than 3 people? Up to maybe 10-15 people, I think this would work fine with tighter RLS policies and some role-based UI logic. Beyond that, you'd start wanting features like automations, custom workflows, and reporting that would take serious engineering effort. At that point, a dedicated CRM tool makes more sense — just maybe not Monday.com.

How does the real-time sync perform? Supabase Realtime uses WebSockets under the hood, and for our use case (3 concurrent users, low-frequency updates), it's essentially instant. We measured the end-to-end latency from one user dragging a card to another user seeing the update: typically 80-150ms. That's faster than we can perceive.

Did you consider open-source CRM alternatives like Twenty or Folk? We looked at Twenty (the open-source CRM that launched in 2024) and it's impressive. But it's a full CRM with way more features than we needed, and self-hosting it requires more infrastructure. Our goal was to build exactly what we needed and nothing more. If Twenty had existed when we started and had a simpler kanban-focused mode, we might have gone that route.

Would you build custom internal tools for clients too? We have, actually. Several clients have come to us after outgrowing tools like Monday.com, Notion, or Airtable for specific workflows. We typically build these with Astro or Next.js on the frontend and Supabase or a headless CMS on the backend. If that sounds like something you need, we should talk.