我们为什么关闭了 Monday.com 并用 Astro + Supabase 构建了自己的 CRM
你的发票到了:Monday.com 每月 $48。三个座位,Pro 计划。每次冲刺审查时,有人都会说同样的话——"我讨厌这个东西"。不是因为软件坏了。Monday.com 确实令人印象深刻。它只是做了 200 件你的流程不需要的事情,而在你真正运行的 4 个工作流上却表现不佳。列无法按照你的销售流程的方式排序。自动化会触发两次。移动视图让你的账户经理想砸手机。所以我们做了任何拥有 Supabase 额度和太多看法的开发公司会做的事:我们重建了它。11 天,1,200 行 Astro 代码,零遗憾。这是架构、拖动控制器和让我们 CFO 笑容满面的账单。
这不是一篇"我们比 Monday.com 聪明"的文章。这是一篇"我们有非常具体的需求、一个我们已经知道的技术栈和一个周末"的文章。以下是使用 Astro、Supabase 和对 SaaS 臃肿的健康怨恨来构建自定义 CRM 看板的完整故事。
目录
- 为什么 Monday.com 不再适合我们
- 我们实际需要什么
- 选择技术栈:Astro + Supabase
- 数据库设计:保持简单
- 构建看板
- 使用 Supabase Realtime 的实时更新
- 身份验证和行级安全
- 部署和托管成本
- 我们会做的不同的事情
- 常见问题

为什么 Monday.com 不再适合我们
让我具体说明我们的不满,因为对 SaaS 工具的笼统抱怨没有用。
问题 1:数据模型在与我们对抗。 Monday.com 的思维方式是"看板"、"项目"和"列"。我们的代理公司的思维方式是交易、联系人和项目——三个具有相互关系的不同实体。我们需要一个交易引用一个联系人,一个项目引用一个交易。Monday.com 可以通过链接列来做到这一点,但很笨拙。每次有人创建新交易时,他们都必须手动将其链接到正确的联系人。人们会忘记。数据变得混乱。
问题 2:看板视图无法执行我们想要的操作。 我们需要看到按阶段分组的交易,并按来源(推荐、有机、外展)进行颜色编码。Monday.com 的看板视图让你按一个状态列分组。就这样。你无法在不使用命名约定进行破解的情况下叠加第二个视觉维度。
问题 3:速度。 这个是主观的,但对于我们所做的事情,Monday.com 感觉很慢。点击交易,等待侧面板加载,滚过我们不使用的字段,找到我们需要的一个注释。每个交互都有足够的延迟来感受摩擦。
问题 4:成本轨迹。 对于三个人来说,每月 $48 并不贵。但我们在考虑第四个团队成员,Monday.com 的定价跳跃到 Pro 计划 5 个座位每月 $60(你不能只买 4 个)。这对于一个我们积极抱怨的工具来说是每年 $720。
引爆点
实际的触发因素令人尴尬地平凡。一个潜在客户给我们发了电子邮件,两个团队成员都回复了,因为谁都无法从 Monday.com 中看出谁"领取"了线索。通知系统没有足够清楚地显示它,我们添加自己到"人员"列的黑客解决方案并不可靠。那是我打开 VS Code 而不是 Monday.com 的时候。
我们实际需要什么
在编写任何代码之前,我们花了大约一个小时列出我们的 CRM 确切需要做什么。不是什么会很好。什么是真正必要的。
以下是列表:
- 看板,其列为交易阶段:线索 → 已联系 → 提案 → 谈判 → 成交 → 丢失
- 交易卡显示:联系人姓名、交易价值、来源标签(颜色编码)、分配的团队成员、当前阶段的天数
- 列之间的拖放,带有即时持久化
- 交易详情视图,包含注释(markdown)、联系人信息和简单的活动日志
- 实时同步,以便两个查看看板的人看到相同的状态
- 联系人数据库,包含基本信息(姓名、电子邮件、公司、注释)
- 简单的身份验证——只有我们的团队,没有公开访问权限
就是这样。没有甘特图。没有时间跟踪。没有自动化引擎。没有 47 种不同的列类型。只是一个看板,由具有真实关系的真实数据库支持。
选择技术栈:Astro + Supabase
我们是一家 Astro 开发商店,所以 Astro 是显而易见的起点。但值得解释为什么它在这里实际上是有意义的,因为 Astro 作为"静态站点生成器"的声誉大大低估了它。
自 Astro 4.x(现在 2026 年的 5.x),服务器端渲染与按需路由是第一流的功能。你可以构建完整的动态应用程序。我们使用 Astro 的混合渲染模式:大多数页面在请求时由服务器渲染,但我们仍然可以预先渲染登录页面之类的东西。
对于交互式看板本身,我们使用 React 岛。这是 Astro 对于像这样的应用程序的杀手级功能——应用程序的外壳(导航、布局、身份验证检查)使用零 JS 进行服务器渲染,看板作为具有 client:load 的单个交互岛挂载。
Supabase 是数据库选择的几个原因:
| 功能 | 为什么重要 |
|---|---|
| Postgres 引擎 | 真实关系数据库、真实外键、真实查询 |
| 实时订阅 | 内置 WebSocket 支持用于实时更新 |
| 行级安全 (RLS) | 数据库级别的身份验证规则,而不仅仅是应用程序级别 |
| JS 客户端库 | 干净的 API、良好的 TypeScript 支持 |
| 免费套餐 | 我们的使用情况舒适地适合 Supabase 的免费计划 |
| 自托管选项 | 如果我们超出免费套餐,我们可以自己运行 |
我们简要考虑了其他选项:
| 选项 | 为什么我们放弃了 |
|---|---|
| Firebase / Firestore | NoSQL 使关系数据很尴尬。之前受过伤。 |
| PlanetScale | 很好,但没有内置的实时。需要单独的 WebSocket 解决方案。 |
| Neon + Prisma | 坚实的组合,但 Supabase 在一个中提供了身份验证+实时+数据库。 |
| 在 Next.js 上构建 | 我们很了解 Next.js(我们定期使用它构建),但对于内部工具,Astro 的岛屿架构意味着非交互部分的客户端 JS 更少。 |

数据库设计:保持简单
该架构有四个表。就这样。
-- Contacts: 我们交谈的人和公司
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: 我们看板上的管道项目
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()
);
-- Activity 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', 等
details jsonb,
created_at timestamptz default now()
);
-- Deal notes: 附加到交易的 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);
// 订阅实时更改
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('无法移动交易');
}
// 记录活动
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="交易看板">
<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);
};
}, []);
}
一个陷阱:当你移动交易时,你通过实时订阅获得自己的更改。如果你不小心,这会导致视觉故障,卡片会跳动。我们通过用时间戳标记乐观更新并忽略与最近本地更改匹配的实时事件来处理这个问题。这是几行额外的代码,但它使用户体验感觉扎实。
身份验证和行级安全
由于这是一个内部工具,身份验证很简单。我们使用带有电子邮件/密码的 Supabase 身份验证。三个账户。没有注册流程——我们在 Supabase 仪表板中手动创建了账户。
行级安全是它变得有趣的地方。即使这是一个内部工具,RLS 也意味着即使我们在应用程序代码中搞砸了,我们的数据库也不会意外泄露数据。
-- 只有经过身份验证的用户才能看到交易
alter table deals enable row level security;
create policy "经过身份验证的用户可以读取所有交易"
on deals for select
to authenticated
using (true);
create policy "经过身份验证的用户可以插入交易"
on deals for insert
to authenticated
with check (true);
create policy "经过身份验证的用户可以更新交易"
on deals for update
to authenticated
using (true);
create policy "经过身份验证的用户可以删除交易"
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 架构,用于我们为客户构建的 无头 CMS 支持的应用程序。
我们会做的不同的事情
自从我们发布 v1 以来已经大约四个月了。以下是我会改变的内容:
从第一天开始使用 Zustand
我们从 React 的内置 useState 和 useContext 开始进行状态管理。当我们添加实时同步、乐观更新和回滚逻辑时,状态管理代码很复杂。我们在两周后迁移到 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 的存储,多年来我们不会达到。实时连接上限也很大——免费计划上你最多可以获得 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? 我们查看了 Twenty(2024 年推出的开源 CRM),它令人印象深刻。但它是一个完整的 CRM,具有比我们需要的更多功能,自托管它需要更多基础设施。我们的目标是构建我们确切需要的东西,仅此而已。如果 Twenty 在我们开始时就存在,并且有一个更简单的以看板为中心的模式,我们可能已经走了那条路。
你也会为客户构建自定义内部工具吗? 我们有过。几个客户在超出 Monday.com、Notion 或 Airtable 用于特定工作流后来找我们。我们通常使用前端的 Astro 或 Next.js 和后端的 Supabase 或无头 CMS 来构建这些。如果这听起来像你需要的东西,我们应该谈谈。