我們的 CRM Kanban 內部構造:為什麼我們用 Astro + Supabase 重建 Monday.com
我們每月為 Monday.com 支付 $48。三個座位,Pro 計劃。每週都有人在團隊中說一些「我討厭這個」的話。不是因為 Monday.com 很差——它確實是令人印象深刻的軟體。但它做了大約 200 件我們不需要的事情,卻無法完成我們實際想要的 4 件事。所以我們做了任何一個開發者眾多、意見很多的代理公司會做的事:我們自己建造了一個。
這不是一篇「我們比 Monday.com 更聰明」的文章。這是一篇「我們有非常具體的需求、我們已經知道的技術棧,以及一個週末」的文章。以下是使用 Astro、Supabase 和對 SaaS 臃腫的健康憤恨來構建自訂 CRM 看板的完整故事。
目錄
- 為什麼 Monday.com 不再適合我們
- 我們實際需要什麼
- 選擇技術棧:Astro + Supabase
- 資料庫設計:保持簡單
- 構建看板
- 使用 Supabase Realtime 進行實時更新
- 身份驗證和行級安全性
- 部署和託管成本
- 我們會做的不同之處
- 常見問題

為什麼 Monday.com 不再適合我們
讓我具體說明我們的挫折感,因為對 SaaS 工具的模糊抱怨是無用的。
問題 1:資料模型在與我們作對。 Monday.com 用「看板」和「項目」以及「欄位」來思考。我們的代理公司用交易、聯絡人和項目來思考——三個不同的實體,它們之間有關係。我們需要一個交易來參考一個聯絡人,一個項目來參考一個交易。Monday.com 可以用連結欄位來做到這一點,但很笨拙。每次有人創建新交易時,他們都必須手動將其連結到正確的聯絡人。人們忘記了。資料變得凌亂。
問題 2:看板視圖無法做我們想要的。 我們需要看到按階段分組的交易,AND 按來源著色(推薦、有機、外展)。Monday.com 的看板視圖讓你按一個狀態欄分組。就這樣。你不能在沒有用命名慣例破解的情況下分層一個第二視覺維度。
問題 3:速度。 這個是主觀的,但對於我們做的事情,Monday.com 感覺很慢。點擊一個交易,等待側面板載入,滾過我們不使用的欄位,找到我們需要的一個備註。每次互動都有剛好足夠的延遲來造成摩擦。
問題 4:成本軌跡。 在 $48/月三個人的情況下,這並不昂貴。但我們在考慮第四個團隊成員,Monday.com 的定價跳到 $60/月的 Pro 計劃,5 個座位(你不能買 4 個)。那是 $720/年用於一個我們主動抱怨的工具。
轉折點
實際的觸發點是尷尬地平凡。一個潛在客戶給我們發來電子郵件,兩個團隊成員都回覆了,因為都無法從 Monday.com 看出誰「聲稱」了潛在客戶。通知系統沒有清楚地表面它,我們的駭客式解決方法是將自己添加到「人員」欄位,但不可靠。那時我打開 VS Code 而不是 Monday.com。
我們實際需要什麼
在編寫任何代碼之前,我們花了大約一小時列出我們的 CRM 實際需要做的事情。不是什麼會很好。什麼是實際必要的。
以下是清單:
- 看板,交易階段的欄位:潛在客戶 → 已聯絡 → 提案 → 談判 → 已贏取 → 已失去
- 交易卡顯示:聯絡人名稱、交易價值、來源標籤(著色)、分配的團隊成員、當前階段中的天數
- 在欄之間拖放,即時持久化
- 交易詳細視圖,包括備註(markdown)、聯絡人資訊和簡單的活動日誌
- 實時同步,以便兩個看著看板的人看到相同的狀態
- 聯絡人資料庫,包含基本資訊(名稱、電子郵件、公司、備註)
- 簡單驗證——只是我們的團隊,沒有公開訪問
就是這樣。沒有甘特圖。沒有時間追蹤。沒有自動化引擎。沒有 47 種不同的欄類型。只是一個由真實資料庫以及真實關係支持的看板。
選擇技術棧:Astro + Supabase
我們是 Astro 開發商店,所以 Astro 是顯而易見的起點。但值得解釋為什麼它實際上在這裡有意義,因為 Astro 作為「靜態網站生成器」的聲譽大大低估了它。
自從 Astro 4.x(現在在 2025 年使用 5.x),按需路由的伺服器端渲染是一級功能。你可以構建完整的動態應用。我們使用 Astro 的混合渲染模式:大多數頁面都是按需伺服器渲染的,但我們仍然可以預渲染諸如登入頁面之類的東西。
對於互動式看板本身,我們使用 React island。這是 Astro 對於這樣的應用的殺手鐧——應用的外殼(導航、佈局、身份驗證檢查)是用零 JS 伺服器渲染的,看板裝載為帶有 client:load 的單一互動式 island。
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 的 island 架構意味著非互動部分的客戶端 JS 更少。 |

資料庫設計:保持簡單
該模式有四個表格。就這樣。
-- 聯絡人:我們交談的人和公司
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 island 裝載的 React 元件。我們為拖放使用 @dnd-kit/core,因為它是 2025 年 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);
// 訂閱實時更改
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);
};
}, []);
}
一個注意事項:當您移動交易時,您通過實時訂閱獲得自己的變更。如果你不小心,這會導致視覺故障,卡片會跳動。我們通過使用時間戳標記樂觀更新並忽略與最近本地更改相匹配的實時事件來處理此問題。這只是幾行額外的代碼,但它使 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 個)和實時連接。我們使用免費層容量的大約 1%。
將其與 Monday.com 進行比較:
| Monday.com | 我們的自訂 CRM | |
|---|---|---|
| 月成本 | $48(3 個座位,Pro) | $0 |
| 年成本 | $576 | $0 |
| 構建時間 | 0 小時 | ~20 小時 |
| 維護 | 0 小時/月 | ~1 小時/月 |
在我們的內部小時費率下,20 小時的開發時間比 $576/年 值得多得多。但數學錯過了重點。我們構建這個部分是因為我們想要,部分是因為它對我們特定工作流是更好的工具,部分是因為那 20 小時教了我們從那以後在客戶專案中使用過的東西。我們已經將類似的 Astro + Supabase 架構應用於我們為客戶構建的 headless CMS 支援的應用。
我們會做的不同之處
自從我們推出 v1 以來,已經大約四個月了。以下是我會改變的:
從第一天起使用 Zustand
我們開始使用 React 的內置 useState 和 useContext 進行狀態管理。到我們添加實時同步、樂觀更新和回滾邏輯時,狀態管理代碼是糾纏的。我們在兩週後遷移到 Zustand。應該從那裡開始。
更早添加搜尋
我們直到第三週才構建搜尋,那三週手動掃描欄來查找特定交易是令人惱火的。Supabase 上的簡單 ilike 查詢會花 30 分鐘來實現。
鍵盤快捷鍵
仍然沒有添加這些,但我們想要它們。按 N 創建新交易,按 / 搜尋,按 1-6 按階段篩選。小事情在你一天多次使用該工具時加起來。
更好的行動裝置視圖
看板在行動裝置上工作,技術上。但六欄不適合手機螢幕。我們需要行動裝置的列表視圖。還沒有優先考慮它,因為我們很少在手機上檢查 CRM,但會很好。
常見問題
構建 CRM 看板需要多長時間? 第一個可用版本花費了大約 20 小時,分散在週末和幾個晚上。這讓我們得到了看板、交易詳情、拖放和基本驗證。自那以後我們可能又花了 10 小時的改進,如搜尋、更好的行動裝置樣式和錯誤修復。
為什麼選擇 Astro 而不是 Next.js 用於動態應用? Astro 的 island 架構意味著應用的非互動部分(佈局、導航、靜態頁面)運送零 JavaScript。看板本身是在加載時進行水合的 React island。對於互動式表面積集中在一個元件的內部工具,這是一個很好的適合。我們為客戶專案使用 Next.js,其中互動性分布在頁面中。
Supabase 的免費層真的對 CRM 足夠嗎? 對於一個小團隊,絕對是。我們有大約 200 個交易、150 個聯絡人和幾千個活動日誌項目。那是數千字節的資料。Supabase 的免費層給你 500MB 的存儲,我們多年都不會達到。實時連接上限也很慷慨——免費計劃上最多 200 個併發連接。
備份怎麼樣?
Supabase 在 Pro 計劃($25/月)中包含每日備份,但我們在免費層。我們通過我們已經有的 $5/月 VPS 上的 cron 作業每週運行一次 pg_dump。它不是光鮮亮麗的,但它有效。我們也有一個 Supabase 專案克隆,如果出問題,我們可以恢復到。
這種方法能否適用於超過 3 人的團隊? 我認為最多 10-15 人,這會很好地進行,具有更嚴格的 RLS 策略和一些基於角色的 UI 邏輯。超過那個,你會開始想要諸如自動化、自訂工作流和需要認真工程工作的報告之類的功能。那時,一個專門的 CRM 工具更有意義——只是也許不是 Monday.com。
實時同步的性能如何? Supabase Realtime 在底層使用 WebSockets,對於我們的用例(3 個併發用戶、低頻率更新),它基本上是即時的。我們測量了從一個用戶拖動卡到另一個用戶看到更新的端到端延遲:通常 80-150ms。那比我們能感知的要快。
你考慮過諸如 Twenty 或 Folk 之類的開源 CRM 替代品嗎? 我們看了 Twenty(2024 年推出的開源 CRM)它令人印象深刻。但它是一個完整的 CRM,功能遠超過我們的需求,自託管它需要更多基礎架構。我們的目標是構建我們需要的確切內容等等。如果 Twenty 在我們開始時存在並具有更簡單的看板焦點模式,我們可能已經採用該路線。
你也會為客戶構建自訂內部工具嗎? 我們已經做過了。幾個客戶在超出 Monday.com、Notion 或 Airtable 對特定工作流的工具後來到我們。我們通常在前端使用 Astro 或 Next.js,後端使用 Supabase 或 headless CMS 構建這些。如果那聽起來像是你需要的東西,我們應該交談。