Article

月額0円で始められる顧客管理システム構築法

高田晃太郎
月額0円で始められる顧客管理システム構築法

国内の中堅企業ではSaaS型の顧客管理システム(CRM)の座席課金が年額で数百万円規模に達するケースは珍しくありません。CRMはその中でも席単価が高く、20名体制で年36万〜240万円という幅が一般的です¹²。にもかかわらず、蓄積するデータ構造は「顧客・案件・活動履歴」の三点にほぼ集約できます。要件さえ適切に切り出せば、無料枠のPaaS(Platform as a Service)とオープンソースの組み合わせで十分に運用可能です。実務で再現しやすい検証条件として、1万顧客・同時接続10・月間30万APIコールという現実的な負荷を想定しても、正しいスキーマ設計とキャッシュ、そして**行レベルセキュリティ(RLS: Row-Level Security)**を適用すればセキュアに運用できるケースは多いことが知られています³⁴。ここでは具体的数値を示しながら、月額0円のまま立ち上げ、しきい値を超えたら段階的に有料へ拡張する顧客管理システム(CRM)構築のアプローチを解説します。

月額0円の現実性と前提条件

無料での構築は、無料枠の制約と業務要件の切り分けに尽きます。席課金を避けるには、自社ドメインの認証基盤とベンダーロックインの少ないデータストアを選ぶのが出発点です。今回の前提は、Next.jsをホスティングの無料枠でデプロイし、PostgreSQL互換のマネージド無料枠を活用、認証はEメールのマジックリンクを中心にし、外部アドオン料金が発生する高度なSAML(SAML: シングルサインオン規格)は初期は採用しない構成です(SupabaseのチュートリアルおよびAuth/RLSの実装ガイドはこのアーキテクチャに適合します)⁵。これにより、初期投資をゼロに抑えつつ、将来のSAMLやSCIM(SCIM: ユーザーの自動プロビジョニング)導入に備えた拡張余地を確保します。

規模の具体的な当てはめとして、顧客1万件、案件6万件、活動履歴30万件、添付メタデータ30万件を想定します。顧客1件のサイズを1〜2KB、案件を2〜3KB、活動履歴を0.5〜1KBとすると、合計は概算で300〜600MBの範囲に収まります。テキスト中心であればデータベースの無料枠に適合し、添付ファイルは外部のオブジェクトストレージの無料枠に保存する前提で、データベースはメタデータのみを持つ設計が成立します(Supabase Freeには小規模Postgresとストレージの無料枠が含まれます)⁶。トラフィック面でも、同時接続10・ピーク時2〜5QPS、1リクエストの平均ペイロード10KB、日中9時間の稼働を想定すると、日間のデータ転送は数百MB程度に収まり、無料ホスティングの帯域許容量の範囲で運用できます。

参考として、無料枠で始めるCRMのアーキテクチャを図示します(簡易図)。

[ブラウザ] ──HTTPS── [Next.js(フロント+API)]

                          │ Supabase JS SDK

                [PostgreSQL(メタデータ)]
                          │ RLSでテナント分離

                [Auth(メールリンク)]


            [オブジェクトストレージ(添付)]

実装ガイド:スキーマ、RLS、API、UI

実装の軸は、テナント(組織)分離可能なスキーマとRLS、薄いAPI層、そしてクライアントのキャッシュ戦略です。まずはデータベースのスキーマから示します。複数組織に対応できるよう、すべての業務テーブルにorg_idを持たせ、主キーはUUID、変更検知のためにupdated_atのタイムスタンプを用意します。これが顧客管理システム(CRM)の中核データモデルになります。

-- 1) スキーマ定義(PostgreSQL)
create extension if not exists pgcrypto;

create table orgs (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  created_at timestamptz not null default now()
);

create table users (
  id uuid primary key,
  email text unique not null,
  created_at timestamptz not null default now()
);

create table memberships (
  org_id uuid references orgs(id) on delete cascade,
  user_id uuid references users(id) on delete cascade,
  role text not null check (role in ('owner','admin','member')),
  primary key (org_id, user_id)
);

create table contacts (
  id uuid primary key default gen_random_uuid(),
  org_id uuid not null references orgs(id) on delete cascade,
  name text not null,
  email text,
  phone text,
  company text,
  tags text[] default '{}',
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create table deals (
  id uuid primary key default gen_random_uuid(),
  org_id uuid not null references orgs(id) on delete cascade,
  contact_id uuid references contacts(id) on delete set null,
  title text not null,
  amount numeric(12,2) not null default 0,
  stage text not null check (stage in ('new','qualify','proposal','won','lost')),
  close_date date,
  owner_id uuid references users(id),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create table activities (
  id uuid primary key default gen_random_uuid(),
  org_id uuid not null references orgs(id) on delete cascade,
  deal_id uuid references deals(id) on delete cascade,
  kind text not null check (kind in ('note','call','email','meeting')),
  body text not null,
  occurred_at timestamptz not null default now(),
  created_at timestamptz not null default now()
);

create index on contacts (org_id);
create index on deals (org_id, stage);
create index on activities (org_id, occurred_at desc);

スキーマが整ったら、行レベルセキュリティ(RLS)を有効にし、ユーザーが所属する組織のデータにのみアクセスできるようにします。RLSは誤設定によるデータ漏えいを防ぐため、必ずデフォルト拒否(許可した行だけ見える)の原則で定義します³⁴。

-- 2) RLSポリシー(ユーザー所属orgに限定)
alter table contacts enable row level security;
alter table deals enable row level security;
alter table activities enable row level security;

create view current_memberships as
select m.org_id, m.user_id from memberships m;

create policy contacts_org_policy on contacts
for all using (
  exists (
    select 1 from current_memberships cm
    where cm.org_id = contacts.org_id and cm.user_id = auth.uid()
  )
) with check (
  exists (
    select 1 from current_memberships cm
    where cm.org_id = contacts.org_id and cm.user_id = auth.uid()
  )
);

create policy deals_org_policy on deals
for all using (
  exists (
    select 1 from current_memberships cm
    where cm.org_id = deals.org_id and cm.user_id = auth.uid()
  )
) with check (exists (select 1 from current_memberships cm where cm.org_id = deals.org_id and cm.user_id = auth.uid()));

create policy activities_org_policy on activities
for all using (
  exists (
    select 1 from current_memberships cm
    where cm.org_id = activities.org_id and cm.user_id = auth.uid()
  )
) with check (exists (select 1 from current_memberships cm where cm.org_id = activities.org_id and cm.user_id = auth.uid()));

初期データ投入と検証のために、サンプル組織とユーザー、顧客を投入するスクリプトを用意しておくと動作確認が捗ります。次はNode.jsでの投入例です。アクセストークンやURLは環境変数から読み込み、エラー時には詳細を出力して異常系に素早く気づけるようにします(SupabaseのNext.jsチュートリアルはAuthとRLS連携の実装例を示しています)⁵。

// 3) 初期データ投入スクリプト (Node.js)
import 'dotenv/config';
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);

async function main() {
  try {
    const { data: org, error: orgErr } = await supabase.from('orgs').insert({ name: 'Acme Inc.' }).select().single();
    if (orgErr) throw orgErr;

    const userId = crypto.randomUUID();
    const { error: userErr } = await supabase.from('users').insert({ id: userId, email: 'owner@acme.example' });
    if (userErr) throw userErr;

    const { error: memErr } = await supabase.from('memberships').insert({ org_id: org.id, user_id: userId, role: 'owner' });
    if (memErr) throw memErr;

    const contacts = Array.from({ length: 1000 }).map((_, i) => ({ org_id: org.id, name: `Contact ${i}`, email: `c${i}@acme.example`, company: 'Acme Inc.' }));
    const chunk = 200;
    for (let i = 0; i < contacts.length; i += chunk) {
      const { error } = await supabase.from('contacts').insert(contacts.slice(i, i + chunk));
      if (error) throw error;
    }
    console.log('Seed completed');
  } catch (e) {
    console.error('Seed failed', e);
    process.exit(1);
  }
}

main();

API層はNext.jsのルートハンドラで薄く実装します。入力はスキーマバリデーションを行い、認証情報からテナントを解決したうえで操作を行います。障害時はステータスコードと機械判読可能なエラーコードで返し、クライアントはそれを捕捉して再試行やロールバックを行います。HTTP APIのエラー表現は「Problem Details(RFC 7807)」などの標準に合わせると運用性が高まります¹²。

// 4) Next.js App Router API (app/api/contacts/route.ts)
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const CreateContact = z.object({ name: z.string().min(1), email: z.string().email().optional(), company: z.string().optional() });

async function getOrgId(req: NextRequest) {
  const orgId = req.headers.get('x-org-id');
  if (!orgId) throw new Error('ORG_NOT_RESOLVED');
  return orgId;
}

export async function GET(req: NextRequest) {
  try {
    const orgId = await getOrgId(req);
    const { searchParams } = new URL(req.url);
    const q = searchParams.get('q') ?? '';
    const { data, error } = await supabase.from('contacts').select('*').ilike('name', `%${q}%`).eq('org_id', orgId).limit(50);
    if (error) return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
    return NextResponse.json({ data });
  } catch (e) {
    const code = (e as Error).message;
    return NextResponse.json({ error: code }, { status: 400 });
  }
}

export async function POST(req: NextRequest) {
  try {
    const orgId = await getOrgId(req);
    const body = await req.json();
    const parsed = CreateContact.safeParse(body);
    if (!parsed.success) return NextResponse.json({ error: 'VALIDATION_ERROR', details: parsed.error.flatten() }, { status: 422 });
    const payload = { ...parsed.data, org_id: orgId };
    const { data, error } = await supabase.from('contacts').insert(payload).select().single();
    if (error) return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
    return NextResponse.json({ data }, { status: 201 });
  } catch (e) {
    const code = (e as Error).message;
    return NextResponse.json({ error: code }, { status: 400 });
  }
}

クライアントはキャッシュを前提にし、楽観的更新で体感速度を高めます。TanStack Queryの例では、登録直後にローカルキャッシュへ反映し、失敗時にロールバックします¹¹。これによりサーバー無料枠のCPU制限下でも、体感の応答性を維持できます。

// 5) フロントの楽観的更新(React + @tanstack/react-query)
import { useMutation, useQueryClient } from '@tanstack/react-query';

type Contact = { id: string; name: string; email?: string; company?: string };

export function useCreateContact() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (input: Omit<Contact, 'id'>) => {
      const res = await fetch('/api/contacts', { method: 'POST', headers: { 'content-type': 'application/json', 'x-org-id': localStorage.getItem('orgId')! }, body: JSON.stringify(input) });
      if (!res.ok) throw new Error('NETWORK');
      const json = await res.json();
      return json.data as Contact;
    },
    onMutate: async (newContact) => {
      await qc.cancelQueries({ queryKey: ['contacts'] });
      const prev = qc.getQueryData<Contact[]>(['contacts']) || [];
      const optimistic: Contact = { id: 'temp-' + Date.now(), ...newContact } as Contact;
      qc.setQueryData(['contacts'], [optimistic, ...prev]);
      return { prev };
    },
    onError: (_err, _vars, ctx) => {
      if (ctx?.prev) qc.setQueryData(['contacts'], ctx.prev);
    },
    onSuccess: (saved) => {
      qc.setQueryData<Contact[]>(['contacts'], (old = []) => [saved, ...old.filter(c => !c.id.startsWith('temp-'))]);
    }
  });
}

認証とテナンシーの最小構成

初期はEメールリンク認証を採用し、ユーザー作成時に必ず既存組織への招待リンクを経由させることで、org_idの孤児化を防ぎます。権限は三階層で十分で、オーナーは組織設定と課金、管理者はメンバー管理とデータ全権、メンバーは自分の担当案件の編集と閲覧権限という切り分けが現場のオペレーションに馴染みます。RLSではroleの値に応じて更新可能フィールドを制限することも可能で、たとえば金額フィールドの更新は管理者以上に限定する設計が有効です(SupabaseのチュートリアルやPostgreSQLの公式ドキュメントが参考になります)⁵⁴。

運用設計:監視、バックアップ、性能チューニング

運用でコストゼロを維持するためには、無料で使える観測性と自動バックアップの仕掛けが鍵になります。ログはアプリ側で構造化し、APIの各レスポンスにリクエストIDを付与してフロントからも伝播させると、事象の突き合わせが容易です。エラーレートは1%未満、p95のAPI応答は300ms以下、p99でも1秒以内というSLO(Service Level Objective)を初期の目安にすると、無料枠でも現実的なユーザー体験が得られます(SREの一般的な指針に整合)¹²。

バックアップはpg_dump互換のツールで定期的に取得し、暗号化のうえでアーティファクトに保存します⁹。レプリカやPoint-in-Time-Recovery(PITR: 任意時点復旧)が無料枠で使えない場合でも、毎日フル、毎時差分のスケジュールを組むことで、最大損失時間を1時間に抑えられます(PostgreSQLのPITRの考え方に基づく)¹⁰。次はGitHub Actionsでの例です。

# 6) GitHub ActionsでのDBバックアップ
name: pg-backup
on:
  schedule:
    - cron: '0 2 * * *'
  workflow_dispatch: {}
jobs:
  dump:
    runs-on: ubuntu-latest
    steps:
      - name: Install pg_dump
        run: sudo apt-get update && sudo apt-get install -y postgresql-client
      - name: Dump database
        env:
          PGURL: ${{ secrets.PG_URL }}
        run: |
          export TS=$(date +"%Y%m%d-%H%M%S")
          pg_dump --format=custom "$PGURL" -f dump-$TS.dump
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: pg-dump
          path: dump-*.dump
          retention-days: 7

性能面では、N+1の排除とインデックスの整備が最も効きます。案件一覧ではstageと更新日の複合インデックスを活用し、検索は単語単位の前方一致に寄せると低コストで良好な応答を得られます。全文検索が必要なときは、更新頻度の高いテーブルに対しては部分インデックスやマテリアライズドビューを検討し、夜間の無料CPU枠が空いている時間帯にリフレッシュするようスケジュールします。アプリ側ではHTTPキャッシュのmax-ageとstale-while-revalidateを併用し、一覧系は緩い整合性を許容すると、バックエンドへの負荷を大きく低減できます⁷⁸。

キャッシュと無停止デプロイの両立

Next.jsのfetchにタグ付きキャッシュと再検証を組み合わせ、作成や更新時に該当のタグを無効化する設計にすると、無料枠の関数同時実行数が低い環境でも安定します⁷。フロントの状態管理はサーバーアクションとクライアントキャッシュを状況に応じて切り替え、一覧はサーバー、詳細はクライアントに寄せると、TTFBとCSRのバランスが取りやすくなります。

セキュリティの実効性確保

個人情報の取り扱いでは、列単位でのマスキングと監査ログの二本立てが重要です。メールや電話番号などのPII(個人を特定できる情報)はアプリ層で復号を必要としない限りは平文で扱わず、監査ログには値そのものではなくハッシュ化したフィンガープリントを残すと、誤通知時の情報漏えいリスクを抑制できます。RLS違反を試みたクエリはアプリ層で403にマッピングして可視化し、IPとユーザーをエラー監視ツールで集約すると、悪用兆候の早期検知に役立ちます(RLSの原理と適用は公式ドキュメントが参考になります)⁴。

ROIと拡張戦略:無料の次に備える

無料での立ち上げは、導入初期のリスク回避に寄与します。たとえば席課金4,000円で20名なら月額8万円、年額96万円の固定費を抑えられ、その分をプロセス定義やデータ移行の地ならしに充てる判断も現実的です¹²。上限に近づいたら、有料枠への移行判断を定量で行います。APIのp95が500msを恒常的に超える、1日あたりの書き込みが10万件を超え始める、データベースサイズが無料枠の70%に到達する、業務上の監査要件でレプリカやPITRが必須になる、といった兆候があるときは、課金へ移るタイミングです(無料枠・有料枠の区分は各プロバイダの料金表を参照)⁶。移行では、まず読み取り専用レプリカの追加、次いでコンピュートの垂直スケール、最後に検索系の分離という順でコスト効率を維持できます。

ダウンタイムなしの移行を実現するには、接続先の抽象化と段階的なカットオーバーが有効です。アプリからは接続文字列を環境変数で差し替え、レプリカへの読み取り切り替えを先に行い、書き込みはフリーズウィンドウ内でDNSのTTL短縮と同時に切り替えると安全です。これらの運用手順をプレイブックに落とし込み、ローンチ前に一度はリハーサルを実施しておくと、無料から有料までのライフサイクル全体で安定性を確保できます。

テストデータと品質の担保

無償枠は本番等価な負荷試験が難しいため、サンプルデータの生成と機能テストをCIに組み込み、最低限のリグレッションを担保します。前述のSeedスクリプトをCIで流し、一覧と作成APIに対してp95 300ms以下を満たすことを合格基準とし、パフォーマンス退行をPRの段階で検知すると、無料のままでも品質を維持できます¹²。

まとめ:小さく始めて、最短で価値に辿り着く

無料枠を前提にした顧客管理システム(CRM)は、要件の切り出しと設計の一貫性が保てれば、1万顧客・同時接続10・月間30万APIコールという現実的な規模でも実務に耐えます。スキーマとRLSを確立し、APIは薄く、クライアントはキャッシュ駆動にするという骨格を守れば、ゼロ円でも体感は十分に軽快です。コストは後からでも足せますが、失われた運用のシンプルさは取り戻せません。今日から着手するなら、まずはスキーマを創り、最小の一覧と登録を動かし、バックアップを夜間に走らせてください。

業務は止めずに、コストも増やさないという選択肢は、技術と運用の意思決定で十分に実現可能です。次に取り組むべきは、現場が本当に使う3つの画面を決め、そこにデータを流し込み、1週間でユーザーテストに進むことです。あなたのチームなら、月額0円でも価値に到達できますか。小さく検証し、必要なときだけ賢くお金を使う、その第一歩を踏み出しましょう。

参考文献

  1. Salesforce Sales Cloud 価格(日本)
  2. Zoho CRM 価格(日本)
  3. AWS Blog: Multi-tenant data isolation with PostgreSQL Row Level Security
  4. PostgreSQL Documentation: Row Security Policies
  5. Supabase Docs: Next.js + Supabase Tutorial(Auth/RLS)
  6. Supabase Pricing(Free Tier 概要)
  7. Next.js Docs: Caching and Revalidating
  8. web.dev: stale-while-revalidate 指針
  9. PostgreSQL Documentation: pg_dump
  10. PostgreSQL Documentation: Continuous Archiving and Point-in-Time Recovery
  11. TanStack Query Docs: Optimistic Updates
  12. Google SRE Book: SLIs, SLOs, and SLAs(SLO設計の指針)