發票來了:Monday.com 每月 $48。三個席位、Pro 計畫。而且每次衝刺檢視,有人總會說同一句話——「我討厭這東西。」不是因為軟體壞了。Monday.com 確實令人印象深刻。只是它做了 200 件你的銷售管道不需要的事,卻在你真正需要的 4 個工作流上跌跤。欄位排序不符合你的銷售流程。自動化觸發兩次。行動版檢視讓你的帳戶經理想砸手機。所以我們做了任何有 Supabase 額度和太多意見的開發公司會做的事:我們重建了它。十一天、1,200 行 Astro、零後悔。下面是 Schema、拖曳控制器,還有讓我們首席財務官開心的帳單。

這不是「我們比 Monday.com 更聰明」的文章。這是「我們有非常具體的需求、一個我們已經知道的技術棧,和一個週末」的文章。以下是用 Astro、Supabase 和大量對 SaaS 臃腫的憎恨打造自訂 CRM 看板的完整故事。

目錄

我們的 CRM 看板內部:為什麼我們用 Astro + Supabase 重建 Monday.com

為什麼 Monday.com 對我們不再有效

讓我具體說明我們的挫折,因為對 SaaS 工具的模糊抱怨沒有用。

問題 1:資料模型在與我們對抗。 Monday.com 的思維方式是「看板」和「項目」以及「欄位」。我們的代理機構思考交易、聯絡人和專案——三個不同的實體之間有關係。我們需要一筆交易參考一個聯絡人,一個專案參考一筆交易。Monday.com 可以用連結欄位做到這一點,但很笨拙。每次有人建立新交易時,他們都必須手動將其連結到正確的聯絡人。人們會忘記。資料變得混亂。

問題 2:看板檢視無法做我們想要的。 我們需要看到按階段分組的交易,並按來源(推薦、自然搜尋、主動開發)用顏色編碼。Monday.com 的看板檢視允許你按一個狀態欄位分組。就這樣。你無法在不使用命名慣例進行黑客的情況下分層第二個視覺維度。

問題 3:速度。 這個因人而異,但對於我們正在做的事情,Monday.com 感覺很慢。點擊一筆交易,等待側面板加載,滾動過我們不使用的欄位,找到我們需要的一個備註。每次互動都有足夠的延遲,讓人感到摩擦。

問題 4:成本軌跡。 三個人每月 $48,這不是昂貴的。但我們在考慮第四個團隊成員,Monday.com 的定價跳到 Pro 計畫的 $60/月(5 個席位——你不能只買 4 個)。這是 $720/年用於我們主動抱怨的工具。

臨界點

實際觸發令人尷尬地平凡。一個潛在客戶給我們發了電子郵件,兩個團隊成員都回覆了,因為都無法從 Monday.com 看出誰「聲稱」了線索。通知系統沒有清楚地呈現它,我們添加自己到「人員」欄位的黑客解決方法不可靠。那時我打開 VS Code 而不是 Monday.com。

我們真正需要什麼

在寫任何程式碼之前,我們花了大約一個小時列出我們的 CRM 真正需要做什麼。不是什麼會很好。什麼是真正必要的。

以下是清單:

  1. 看板,欄位為交易階段:線索 → 已聯絡 → 提案 → 協商 → 已贏得 → 已失去
  2. 交易卡片顯示:聯絡人姓名、交易價值、來源標籤(用顏色編碼)、指定團隊成員、在當前階段中的天數
  3. 拖放欄位之間,即時持久化
  4. 交易詳細檢視,附帶備註(Markdown)、聯絡人資訊和簡單活動日誌
  5. 即時同步,所以兩個人查看看板會看到相同狀態
  6. 聯絡人資料庫,基本資訊(姓名、電子郵件、公司、備註)
  7. 簡單認證——只是我們的團隊,沒有公開存取

就這樣。沒有甘特圖。沒有時間追蹤。沒有自動化引擎。沒有 47 種不同的欄位類型。只是一個由真實資料庫支持的看板,具有真實關係。

選擇技術棧:Astro + Supabase

我們是 Astro 開發商店,所以 Astro 是顯而易見的起點。但值得解釋為什麼它在這裡實際上是合理的,因為 Astro 作為「靜態網站生成器」的聲譽被嚴重低估。

由於 Astro 4.x(現在是 2026 年的 5.x),伺服器端渲染和按需路由是一流功能。你可以構建完整的動態應用程式。我們使用 Astro 的混合渲染模式:大多數頁面在請求時進行伺服器渲染,但我們仍然可以預渲染登入頁面之類的內容。

對於互動式看板本身,我們使用 React islands。這是 Astro 對這類應用程式的殺手功能——應用程式的殼層(導航、佈局、認證檢查)是用零 JS 進行伺服器渲染的,看板以 client:load 的單個互動島嶼掛載。

Supabase 是資料庫選擇,原因有幾個:

功能 為什麼很重要
Postgres 基礎 真實關係資料庫、真實外鍵、真實查詢
Realtime 訂閱 內建 WebSocket 支援以獲得即時更新
列級安全性 (RLS) 資料庫層級的認證規則,不只是應用層級
JS 用戶端庫 乾淨的 API、良好的 TypeScript 支援
免費層 我們的使用情況完全符合 Supabase 的免費計畫
自我託管選項 如果我們超出免費層,我們可以自己執行

我們簡要考慮了其他選項:

選項 為什麼我們放棄
Firebase / Firestore NoSQL 使關係資料尷尬。之前被燒過。
PlanetScale 很好,但沒有內建 realtime。需要單獨的 WebSocket 解決方案。
Neon + Prisma 穩實的組合,但 Supabase 在一個地方給我們認證 + realtime + DB。
在 Next.js 上構建 我們很了解 Next.js(我們定期用它構建),但對於內部工具,Astro 的島嶼架構意味著非互動部分的用戶端 JS 更少。

我們的 CRM 看板內部:為什麼我們用 Astro + Supabase 重建 Monday.com - 架構

資料庫設計:保持簡單

Schema 有四個表格。就這樣。

-- 聯絡人:我們交談的人和公司
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()
);

-- 交易:我們看板上的管道項目
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, -- 以美分儲存
  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, -- 用於在欄位內排序
  stage_entered_at timestamptz default now(),
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- 活動日誌:簡單的只追加日誌
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' 等
  details jsonb,
  created_at timestamptz default now()
);

-- 交易備註:附加到交易的 Markdown 備註
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()
);

交易上的 stage_entered_at 欄位是我最喜歡的小決定之一。每次交易移動到新階段時,我們都會更新這個時間戳記。這讓我們可以計算「在當前階段中的天數」而不需要查詢活動日誌。簡單、快速、有用。

position 欄位處理看板欄位內的排序。當你在兩個其他卡片之間拖動卡片時,我們計算新的位置值。我們使用整數間距(位置增加 1000),所以我們很少需要重新平衡。

構建看板

看板是掛載為 Astro 島嶼的 React 元件。我們使用 @dnd-kit/core 進行拖放,因為截至 2026 年它是 React 生態中最易訪問且維護最良好的 DnD 庫。

以下是簡化的結構:

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

  // 訂閱 realtime 變更
  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>
  );
}

moveDeal 函式做樂觀更新——它立即更新本地狀態,然後將更新發送到 Supabase。如果資料庫更新失敗,它會回滾。這使看板感覺即時。

const moveDeal = async (dealId: string, newStage: string, newPosition: number) => {
  // 樂觀更新
  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) {
    // 回滾——從伺服器重新擷取
    await refreshDeals();
    toast.error('Failed to move deal');
  }

  // 記錄活動
  await supabase.from('activities').insert({
    deal_id: dealId,
    user_id: currentUser.id,
    action: 'stage_change',
    details: { from: previousStage, to: newStage },
  });
};

託管這個的 Astro 頁面很小:

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

那個 client:load 指令在做繁重的工作。佈局、導航和頁面殼層都是伺服器渲染的 HTML。看板本身在用戶端上水化。這意味著初始頁面加載很快——瀏覽器立即獲得 HTML,互動式看板在之後啟動。

使用 Supabase Realtime 的即時更新

這是讓 Supabase 成為該項目明確贏家的功能。當一個團隊成員移動交易時,其他團隊成員實時看到它移動。不需要刷新。

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

一個陷阱:當你移動交易時,你通過 realtime 訂閱獲得自己的變更。如果你不小心,這會導致視覺故障,卡片會跳躍。我們通過用時間戳記標記樂觀更新並忽略與最近本地變更相符的 realtime 事件來處理這個問題。這是幾行額外的程式碼,但使 UX 感覺紮實。

認證和列級安全性

由於這是內部工具,認證很簡單。我們使用 Supabase Auth 搭配電子郵件/密碼。三個帳戶。沒有註冊流程——我們在 Supabase 儀表板中手動建立帳戶。

列級安全性是有趣的地方。即使這是內部工具,RLS 也意味著即使我們弄糟應用程式碼,我們的資料庫也不會意外洩露資料。

-- 只有已認證的使用者可以看到交易
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);

是的,這些政策很寬鬆——任何已認證的使用者都可以做任何事。對於三人團隊,這很好。如果我們成長到需要基於角色的權限的規模,RLS 基礎設施已經存在。我們只需收緊政策。

部署和託管成本

有趣的部分來了。讓我們談錢。

服務 計畫 月成本
Supabase 免費層 $0
Vercel(託管 Astro SSR) Pro 計畫(已擁有) $0 增量
網域 現有網域的子網域 $0
總計 $0/月

我們已經在 Vercel 的 Pro 計畫上用於用戶端項目,所以部署一個更多的 SSR 應用程式不會增加成本。Supabase 的免費層提供 500MB 資料庫儲存、50,000 個月活躍使用者(我們有 3 個)和 realtime 連線。我們使用可能不到免費層容量的 1%。

將其與 Monday.com 比較:

Monday.com 我們的自訂 CRM
月成本 $48(3 個席位、Pro) $0
年成本 $576 $0
構建時間 0 小時 ~20 小時
維護 0 小時/月 ~1 小時/月

以我們的內部時薪,20 小時的開發時間價值遠超 $576/年。但這個數學忽略了重點。我們部分出於想要、部分因為它對我們特定工作流程是更好的工具、部分因為那 20 小時教會了我們之後在用戶端項目中使用的東西而構建了這個。我們從此後為客戶構建的 無頭 CMS 支持應用程式 應用了類似的 Astro + Supabase 架構。

我們會做得不同的地方

自從我們發布 v1 以來已經過了大約四個月。以下是我會改變的地方:

從第一天起使用 Zustand

我們從 React 的內建 useState 和 useContext 開始進行狀態管理。到我們添加 realtime 同步、樂觀更新和回滾邏輯時,狀態管理程式碼是糾纏的。我們在兩週後遷移到 Zustand。應該從一開始就開始。

更早地添加搜尋

我們直到第三週才構建搜尋,那三週手動掃描欄位以查找特定交易令人厭煩。一個簡單的 Supabase ilike 查詢本會花 30 分鐘實現。

鍵盤快捷鍵

仍然沒有添加這些,但我們想要它們。按 N 建立新交易、/ 搜尋、1-6 按階段篩選。當你每天在工具中多次時這些小事累積起來。

更好的行動檢視

看板在行動裝置上技術上有效。但六個欄位不符合電話螢幕。我們需要行動的列表檢視。尚未優先考慮它,因為我們很少在電話上檢查 CRM,但這很好。

常見問題

構建 CRM 看板花了多長時間? 第一個可用版本花了大約 20 小時,分佈在一個週末和幾個晚上。那給了我們看板、交易詳細資訊、拖放和基本認證。我們從那時起可能又花了 10 小時進行搜尋、更好的行動風格和錯誤修復等改進。

為什麼選擇 Astro 而不是 Next.js 進行動態應用程式? Astro 的島嶼架構意味著應用程式的非互動部分(佈局、導航、靜態頁面)運送零 JavaScript。看板本身是在載入時水化的 React 島嶼。對於互動表面積集中在一個元件上的內部工具,這是很好的契合。我們為用戶端項目使用 Next.js,其中互動在頁面上分散更多。

Supabase 的免費層對 CRM 來說真的足夠嗎? 對於小團隊,絕對是。我們有大約 200 個交易、150 個聯絡人和幾千個活動日誌項目。那是千字節的資料。Supabase 的免費層提供 500MB 儲存,我們多年內不會達到。realtime 連線上限也很寬鬆——你在免費計畫上最多獲得 200 個並發連線。

備份呢? Supabase 在 Pro 計畫上($25/月)包括每日備份,但我們在免費層。我們通過在我們已經擁有的 $5/月 VPS 上的 cron 工作執行每週 pg_dump。這不是光鮮亮麗的,但它有效。我們也有一個 Supabase 項目複製如果有什麼出錯我們可以恢復到。

這種方法對大於 3 人的團隊有效嗎? 直到可能 10-15 人,我認為這會用更緊密的 RLS 政策和一些基於角色的 UI 邏輯有效。超過那個,你會開始想要自動化、自訂工作流程和報告等功能,這需要認真的工程工作。在那一點,專用 CRM 工具更有意義——只是也許不是 Monday.com。

即時同步性能如何? Supabase Realtime 在底層使用 WebSockets,對於我們的使用情況(3 個並發使用者、低頻率更新),它基本上是即時。我們測量了端到端延遲,從一個使用者拖動卡片到另一個使用者看到更新:通常 80-150ms。那比我們能夠感知的要快。

你是否考慮了開源 CRM 替代品,如 Twenty 或 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.

我們看了 Twenty(2024 年推出的開源 CRM),它令人印象深刻。但它是一個完整的 CRM,具有遠超我們需要的功能,自我託管需要更多基礎設施。我們的目標是構建我們確切需要的東西,不會更多。如果 Twenty 在我們開始時就存在並有更簡單的看板聚焦模式,我們可能會走那條路。

你也會為客戶構建自訂內部工具嗎? 我們有。幾個客戶在超過 Monday.com、Notion 或 Airtable 以用於特定工作流程後已聯絡我們。我們通常使用前端的 Astro 或 Next.js 以及後端的 Supabase 或無頭 CMS 構建這些。如果這聽起來像你需要的東西,我們應該談話