私たちはMonday.comに月額$48を支払っていました。3シート、Proプラン。そして毎週のように、チームの誰かが「これ嫌い」というバージョンを言うのです。Monday.comが悪いからではなく、それは本当に印象的なソフトウェアです。しかし私たちが必要としていない200個のことをしていて、実際に必要な4つのことはできていませんでした。だから、意見の多いデベロッパーでいっぱいのエージェンシーは当然のことをしました:自分たちで構築したのです。

これは「私たちはMonday.comより賢い」という投稿ではありません。「私たちは非常に具体的なニーズを持っていて、既に知っているスタックを持っていて、週末があった」という投稿です。Astro、Supabase、そしてSaaS肥大化への健全な恨みを使ったカスタムCRMカンバンボードを構築する完全なストーリーをここに示します。

目次

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

Monday.comが私たちにとって機能しなくなった理由

私たちのフラストレーションについて具体的に説明します。なぜなら、SaaSツールについてのあいまいな苦情は無用だからです。

問題1:データモデルが私たちに対抗していました。 Monday.comは「ボード」と「アイテム」と「列」で考えます。私たちのエージェンシーはディール、コンタクト、プロジェクトで考えます。3つの異なるエンティティとそれらの間の関係です。ディールがコンタクトを参照する必要があり、プロジェクトがディールを参照する必要があります。Monday.comはリンク列でこれをある程度できますが、厄介です。誰かが新しいディールを作成するたびに、彼らは手動でそれを正しいコンタクトにリンクする必要があります。人々は忘れます。データが散らかります。

問題2:カンバンビューが私たちが必要としていたことはできませんでした。 ディールをステージ別に グループ化され、かつソース(紹介、オーガニック、アウトバウンド)で色分けされているのを見たかったのです。Monday.comのカンバンビューでは、1つのステータス列でグループ化できます。それだけです。命名規則でそれを操作せずに、2番目のビジュアルディメンションを重ねることはできません。

問題3:速度。 これは主観的ですが、Monday.comは私たちがしていたことに対して遅く感じられました。ディールをクリックして、サイドパネルが読み込まれるのを待って、使わないフィールドをスクロールして、必要なメモを見つけます。すべてのインタラクションには、摩擦を感じるのに十分なレイテンシーがありました。

問題4:コスト軌跡。 3人に対して月額$48では高くはありません。しかし、私たちは4番目のチームメンバーを視野に入れていたのに、Monday.comの価格設定は5シートのProプランで$60/月に跳ね上がります(4は購入できません)。これは積極的に不満を言っていたツールに対して年間$720です。

ティッピングポイント

実際のきっかけはおそらく退屈でした。見込み客がメールを送ってきたのに、どちらがリードを「クレーム」したのかMonday.comから判明できなかったため、2人のチームメンバーが返信してしまいました。通知システムはそれを十分に明確に表示せず、「People」列に自分を追加するという私たちのしゃれた解決策は信頼できませんでした。それが私がMonday.comの代わりにVS Codeを開いた時です。

私たちが実際に必要としていたもの

コードを書く前に、私たちのCRMが正確に何をする必要があるかをリストアップするのに約1時間を費やしました。良かったら。実際に必要なもの。

リストです:

  1. カンバンボード、列はディールステージ: Lead → Contacted → Proposal → Negotiation → Won → Lost
  2. ディールカードを表示: コンタクト名、ディール値、ソースタグ(色分け)、割り当てられたチームメンバー、現在のステージでの日数
  3. ドラッグアンドドロップ、列間で、即座に永続化
  4. ディール詳細ビューメモ(マークダウン)、コンタクト情報、簡単なアクティビティログ付き
  5. リアルタイム同期、2人がボードを見ているので同じ状態が見えます
  6. コンタクトデータベース基本情報付き(名前、メール、会社、メモ)
  7. シンプルな認証、チームだけ、公開アクセスなし

それだけです。ガントチャートなし。時間追跡なし。オートメーションエンジンなし。47種類の異なる列タイプなし。本当のデータベースでサポートされた本当の関係を持つ単なるカンバンボード。

スタック選択: Astro + Supabase

私たちはAstro開発ショップなので、Astroは明らかな出発点でした。しかし、なぜそれが実際にここで意味があるのかを説明する価値があります。なぜなら、Astroの「静的サイトジェネレータ」としての評判は大幅に過小評価しているからです。

Astro 4.x以降(そして2025年の5.x)では、オンデマンドルートを使用したサーバーサイドレンダリングはファーストクラスの機能です。完全な動的アプリケーションを構築できます。私たちはAstroのハイブリッドレンダリングモードを使用します:ほとんどのページはリクエストでサーバーレンダリングされますが、ログインページのようなものは事前レンダリングできます。

インタラクティブなカンバンボード自体については、Reactアイランドを使用します。これはこのようなアプリのAstroのキラー機能です。アプリケーションのシェル(ナビ、レイアウト、認証チェック)はゼロJSでサーバーレンダリングされ、カンバンボードはclient:loadを使った単一の対話型アイランドとしてマウントされます。

Supabaseは複数の理由でデータベースの選択でした:

機能 なぜ重要だったのか
Postgresの下 本当のリレーショナルデータベース、本当の外部キー、本当のクエリ
Realtimeサブスクリプション ライブ更新のための組み込みWebSocketサポート
行レベルセキュリティ(RLS) アプリレベルではなくデータベースレベルの認証ルール
JSクライアントライブラリ きれいなAPI、優れたTypeScriptサポート
無料ティア 私たちの使用はSupabaseの無料プランに楽に収まります
セルフホストオプション 無料ティアを超えて成長した場合、自分たちで実行できます

他のオプションを検討しました:

オプション なぜ通過したのか
Firebase / Firestore NoSQLはリレーショナルデータを厄介にします。以前に火傷をしました。
PlanetScale 素晴らしいですが、組み込みのリアルタイムなし。別のWebSocketソリューションが必要です。
Neon + Prisma 堅実なコンボですが、Supabaseは認証+リアルタイム+DBを1つで提供します。
Next.jsの構築 私たちはNext.jsをよく知っています(私たちは定期的にそれを使って構築します)。ただし、内部ツールの場合、Astroのアイランド構造は非インタラクティブ部分のクライアント側JSが少なくなりました。

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

データベース設計:シンプルに保つ

スキーマには4つのテーブルがあります。それだけです。

-- 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: ディールに添付されたマークダウンメモ
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フィールドは、私が最も好む小さな決定の1つです。ディールが新しいステージに移動するたびに、このタイムスタンプを更新します。これにより、アクティビティログをクエリすることなく「現在のステージでの日数」を計算できます。シンプル、高速、有用。

positionフィールドはカンバン列内の順序を処理します。カードを2つの他の間でドラッグするとき、新しい位置値を計算します。整数スペーシング(位置は1000ずつ増加)を使用するため、再バランスが必要になることはめったにありません。

カンバンボードの構築

カンバンボードはAstroアイランドとしてマウントされたReactコンポーネントです。Reactエコシステムで最もアクセス可能で十分に保守されているDnDライブラリであるため、@dnd-kit/coreをドラッグアンドドロップに使用しました。

簡略化された構造:

// 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がこのプロジェクトの明確な勝者になるきっかけとなった機能です。1人のチームメンバーがディールを移動すると、他のチームメンバーはそれをリアルタイムで移動するのを見ます。更新不要です。

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

1つの落とし穴:あなたがディールを移動すると、リアルタイムサブスクリプション経由であなた自身の変更を取得します。注意しないと、これはカードがジャンプするビジュアルグリッチを引き起こします。私たちはこれを処理するために、楽観的更新をタイムスタンプでタグ付けし、最近のローカル変更と一致するリアルタイムイベントを無視します。数行のコードですが、UXを堅牢に感じさせます。

認証と行レベルセキュリティ

これは内部ツールなので、認証はシンプルです。メール/パスワードでSupabase Authを使用します。3つのアカウント。サインアップフローなし。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);

はい、これらのポリシーは許可的です。認証されたユーザーは何でもできます。3人チームでは、それは問題ありません。より多くに成長する場合、ロールベースの許可が必要な場合、RLSインフラストラクチャはすでに存在します。ポリシーを厳しくするだけです。

デプロイメントとホスティングコスト

ここが楽しい部分です。お金について話しましょう。

サービス プラン 月額費用
Supabase 無料ティア $0
Vercel(Astro SSRをホスト) Proプラン(既にあった) $0増分
ドメイン 既存ドメインのサブドメイン $0
合計 $0/月

私たちはクライアントプロジェクトのためにVercelのProプランにすでにいたので、1つ以上の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時間が私たちが以来クライアントプロジェクトで使用した物を教えてくれたからです。その後、ヘッドレスCMSバックアップアプリケーションを構築するためにクライアント向けの同様のAstro + Supabaseアーキテクチャを適用しました。

違う方法で行うこと

v1を船出してから約4ヶ月経ちました。ここで変えるのは何ですか:

最初からZustandを使用する

Reactの組み込みのuseStateとuseContextで状態管理を開始しました。リアルタイム同期、楽観的更新、ロールバックロジックを追加した時点で、状態管理コードは複雑でした。2週間後にZustandに移行しました。最初にそこで始めるべきでした。

より早く検索を追加する

3週目まで検索を構築しなかったため、特定のディールのために列を手動でスキャンしているその3週間は面倒でした。Supabaseの単純なilikeクエリは30分で実装できたでしょう。

キーボードショートカット

まだ追加していませんが、欲しいです。Nを押して新しいディールを作成し、/で検索し、1-6でステージでフィルタリングします。1日に複数回ツールを使う場合、小さなことが追加されます。

より良いモバイルビュー

カンバンボードはモバイルで技術的に動作します。しかし、6つの列がスマートフォン画面に収まりません。モバイル用のリストビューが必要です。スマートフォンでCRMをめったに確認しないため、優先順位を付けていませんが、あると良いです。

FAQ

CRMカンバンボードの構築にはどのくらい時間がかかりましたか?

最初の使用可能なバージョンは、週末といくつかの夜に広がる約20時間かかりました。これにより、カンバンボード、ディール詳細、ドラッグアンドドロップ、基本認証を取得しました。その後、検索、より良いモバイルスタイル、バグ修正などの改善にさらに約10時間を費やしました。

動的なアプリの場合、Next.jsの代わりにAstroを使うのはなぜですか?

Astroのアイランド構造は、アプリの非インタラクティブ部分(レイアウト、ナビ、静的ページ)がゼロJavaScriptを出荷することを意味します。カンバンボード自体はロード時にハイドレートするReactアイランドです。ページ全体にインタラクティビティがより分散している内部ツールの場合、これは素晴らしいフィットです。クライアントプロジェクトにはNext.jsを使用します

Supabaseの無料ティアはCRMで本当に十分ですか?

小さなチームの場合、絶対に。約200ディール、150コンタクト、数千のアクティビティログエントリがあります。これはデータのキロバイトです。Supabaseの無料ティアは500MBのストレージを提供します。これは数年間は達成しません。リアルタイム接続キャップも寛容です。無料プランで最大200の同時接続を取得します。

バックアップについては?

Supabaseには、Proプラン($25/月)の日次バックアップが含まれていますが、無料ティアにいます。既にあった$5/月VPS上の定期ジョブを介して週次pg_dumpを実行します。派手ではありませんが、機能します。また、何か問題が発生した場合に復元できるSupabaseプロジェクトクローンもあります。

このアプローチは3人を超えるチームで機能しますか?

10~15人までなら、より厳しいRLSポリシーと一部のロールベースUIロジックでうまくいくと思います。その範囲を超えて、オートメーション、カスタムワークフロー、レポーティングが必要になり、深刻なエンジニアリング作業が必要になります。その時点で、専用CRMツールは意味があります。ただし、Monday.comではないかもしれません。

リアルタイム同期のパフォーマンスはどうですか?

Supabase Realtimeはフードの下でWebSocketsを使用します。ユースケース(同時ユーザー3人、低頻度更新)では、本質的にインスタントです。1人のユーザーがカードをドラッグするからもう1人のユーザーが更新を見るまで、エンドツーエンドレイテンシーを測定しました:通常80~150ms。これは知覚できるより速いです。

TwentyやFolkなどのオープンソースCRM代替案を検討しましたか?

Twenty(2024年に立ち上がったオープンソースCRM)とFolkを見たところ、それは印象的です。しかし、それは私たちが必要としたものよりもはるかに多くの機能を持つ完全なCRMです。自己ホストするには、より多くのインフラストラクチャが必要です。私たちの目標は、必要とされるものだけを構築することでした。Twentyが存在していて、より単純なカンバン中心のモードがあったとしたら、その経路に行ったかもしれません。

クライアント向けのカスタム内部ツールも構築しますか?

実際にしています。複数のクライアントが、Monday.com、Notion、またはAirtableなどのツールを特定のワークフローに対して成長した後、私たちに来ています。通常、フロントエンドはAstroまたはNext.js、バックエンドはSupabaseまたはヘッドレスCMSで構築します。それがあなたが必要なもののように聞こえるなら、私たちは話すべきです