我们每月为 Monday.com 支付 $48。三个席位,Pro 计划。每个星期,团队中的某个人都会说某种版本的"我讨厌这个。"不是因为 Monday.com 不好——它确实是令人印象深刻的软件。但它做了大约 200 件我们不需要的事情,而且无法很好地完成我们实际需要的 4 件事。所以我们做了任何一个有太多想法的开发人员组成的机构会做的事情:我们自己构建了一个。

这不是一篇"我们比 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 计划 5 个席位 $60/月(你不能只买 4 个)。这是我们积极抱怨的工具的 $720/年。

引爆点

真正的触发因素尴尬地平凡。一个潜在客户给我们发来电子邮件,两个团队成员都回复了,因为他们都无法从 Monday.com 判断谁"声称"了这条线索。通知系统没有足够清楚地显示它,我们的将自己添加到"人员"列的黑客变通方案不可靠。那时我打开 VS Code 而不是 Monday.com。

我们实际需要什么

在编写任何代码之前,我们花了大约一个小时列出我们的 CRM 实际需要做什么。不是会很好的。什么是真正必要的。

这是列表:

  1. 看板,其列为交易阶段:线索 → 已联系 → 提案 → 协商 → 已赢 → 已失
  2. 交易卡显示:联系人名称、交易价值、来源标签(颜色编码)、分配的团队成员、当前阶段中的天数
  3. 在列之间拖放并立即持久化
  4. 交易详细视图包括笔记(降价)、联系人信息和简单活动日志
  5. 实时同步,以便两个查看看板的人看到相同的状态
  6. 联系人数据库包含基本信息(姓名、电子邮件、公司、笔记)
  7. 简单身份验证——仅我们的团队,无公开访问

就这样。没有甘特图。没有时间追踪。没有自动化引擎。没有 47 种不同的列类型。只是一个由真实数据库和真实关系支持的看板。

选择技术栈:Astro + Supabase

我们是一个 Astro 开发商店,所以 Astro 是显而易见的起点。但值得解释为什么它在这里实际上是有意义的,因为 Astro 作为"静态网站生成器"的声誉大大低估了它。

自 Astro 4.x(现在 2025 年的 5.x)以来,带有按需路由的服务器端渲染是一流的功能。您可以构建完整的动态应用程序。我们使用 Astro 的混合渲染模式:大多数页面在请求时进行服务器渲染,但我们仍然可以预渲染登录页面等内容。

对于交互式看板本身,我们使用 React island。这是 Astro 对于这样的应用的杀手锏——应用的外壳(导航、布局、身份验证检查)以零 JS 进行服务器渲染,看板作为带有 client:load 的单个交互式 island 装载。

Supabase 是数据库选择的原因有以下几个:

功能 为什么这很重要
幕后的 Postgres 真实的关系数据库、真实的外键、真实的查询
实时订阅 内置 WebSocket 支持实时更新
行级安全 (RLS) 数据库级别的身份验证规则,而不仅仅是应用级别
JS 客户端库 干净的 API、很好的 TypeScript 支持
免费层 我们的使用舒适地适应 Supabase 的免费计划
自托管选项 如果我们超出免费层,我们可以自己运行

我们简要考虑了其他选项:

选项 为什么我们通过了
Firebase / Firestore NoSQL 使关系数据尴尬。之前被烧过。
PlanetScale 很好,但没有内置实时。需要单独的 WebSocket 解决方案。
Neon + Prisma 坚实的组合,但 Supabase 在一个中给了我们身份验证+实时+ DB。
基于 Next.js 构建 我们很了解 Next.js(我们定期用它构建),但对于内部工具,Astro 的 island 架构意味着非交互部分的客户端 JS 更少。

我们的 CRM 看板内部:为什么我们用 Astro + Supabase 重建 Monday.com - 架构

数据库设计:保持它足够简单

架构有四个表。就这样。

-- 联系人:我们交谈的人和公司
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()
);

-- 交易笔记:附加到交易的降价笔记
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);
    };
  }, []);
}

一个陷阱:当你移动一个交易时,你通过实时订阅收到你自己的更改。如果你不小心,这会导致卡跳跃的视觉故障。我们通过用时间戳标记乐观更新并忽略匹配最近本地更改的实时事件来处理此问题。它是几行额外的代码,但它使用户体验感觉扎实。

身份验证和行级安全

由于这是一个内部工具,身份验证很简单。我们使用带有电子邮件/密码的 Supabase Auth。三个账户。没有注册流程——我们在 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 的 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 政策和一些基于角色的用户界面逻辑。超越这一点,你会开始想要自动化、自定义工作流和报告等功能,这将需要严肃的工程工作。那时,一个专门的 CRM 工具更有意义——只是也许不是 Monday.com。

实时同步性能如何? Supabase Realtime 在幕后使用 WebSocket,对于我们的用例(3 个并发用户、低频率更新),它基本上是即时的。我们测量了从一个用户拖动卡到另一个用户看到更新的端对端延迟:通常 80-150ms。那比我们能感知的快。

你考虑过开源 CRM 替代品如 Twenty 或 Folk 吗? 我们查看了 Twenty(2024 年推出的开源 CRM)并且它令人印象深刻。但它是一个具有远超我们需要的功能的完整 CRM,自托管它需要更多基础设施。我们的目标是构建恰好我们需要的东西,没有更多。如果 Twenty 在我们开始时就存在并且有一个更简单的看板焦点模式,我们可能会选择那个路线。

你也会为客户构建自定义内部工具吗? 我们有过。几个客户在超越 Monday.com、Notion 或 Airtable 对特定工作流后来找我们。我们通常使用前端的 Astro 或 Next.js 以及后端的 Supabase 或无头 CMS 构建这些。如果这听起来像你需要的东西,我们应该谈话