Supabase Realtime でチャット機能を実装する

2026.02.04
Share:

はじめに

「チャット機能を作りたいけど、WebSocket サーバーを自前で立てるのは大変そう...」

そう思ったことはありませんか?

私も BandBridge(ミュージシャンマッチングサービス)を開発していたとき、同じことを考えていました。マッチングが成立したら、ユーザー同士がメッセージをやり取りできる機能が必要でした。でも、WebSocket サーバーを自前で運用するのは、個人開発には荷が重い。

そこで選んだのが Supabase Realtime です。

この記事では、BandBridge でリアルタイムチャット機能を実装した経験をもとに、Supabase Realtime の使い方を解説します。

Supabase Realtime とは

Supabase Realtime は、PostgreSQL の変更をリアルタイムでクライアントに配信する機能です。テーブルに INSERT / UPDATE / DELETE が発生すると、購読しているクライアントに即座に通知されます。

2026年1月7日、BandBridge の初期開発時の開発日記にこう書きました:

Supabase: PostgreSQL + Auth + Storage + Realtime がオールインワン。個人開発でバックエンド工数を最小化

認証、ストレージ、データベース、そしてリアルタイム機能が一つのサービスに統合されている。これが Supabase の大きな魅力です。

Realtime の2つのモード

開発日記にはこう記録しています:

Supabase Realtime は broadcastpostgres_changes の2種類がある

モード用途特徴
broadcastクライアント間の直接通信DB を経由しない、高速
postgres_changesDB 変更の購読INSERT/UPDATE/DELETE をリッスン

チャット機能では、postgres_changes を使うのが一般的です。メッセージをデータベースに保存しつつ、その変更を購読者に配信できるからです。

チャット機能の設計

BandBridge のチャット機能は、以下のような設計にしました:

データベーススキーマ

-- 会話(チャットルーム)
create table conversations (
  id uuid primary key default gen_random_uuid(),
  created_at timestamp with time zone default now()
);

-- 会話の参加者
create table conversation_participants (
  conversation_id uuid references conversations(id),
  user_id uuid references auth.users(id),
  joined_at timestamp with time zone default now(),
  last_read_at timestamp with time zone,
  primary key (conversation_id, user_id)
);

-- メッセージ
create table messages (
  id uuid primary key default gen_random_uuid(),
  conversation_id uuid references conversations(id),
  sender_id uuid references auth.users(id),
  content text not null,
  created_at timestamp with time zone default now()
);

既読管理

開発日記から:

未読表示・既読マーキング(last_read_at JSONB カラム)

conversation_participants テーブルの last_read_at カラムで既読状態を管理します。ユーザーがチャットを開いたときにこのカラムを更新し、最新メッセージの created_at と比較することで未読を判定します。

Realtime の実装

基本的な購読パターン

React で Supabase Realtime を使う基本的なパターンはこうなります:

import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";

export function useMessages(conversationId: string) {
  const [messages, setMessages] = useState<Message[]>([]);

  useEffect(() => {
    // 既存メッセージを取得
    const fetchMessages = async () => {
      const { data } = await supabase
        .from("messages")
        .select("*")
        .eq("conversation_id", conversationId)
        .order("created_at", { ascending: true });

      if (data) setMessages(data);
    };

    fetchMessages();

    // Realtime 購読を開始
    const channel = supabase
      .channel(`messages:${conversationId}`)
      .on(
        "postgres_changes",
        {
          event: "INSERT",
          schema: "public",
          table: "messages",
          filter: `conversation_id=eq.${conversationId}`,
        },
        (payload) => {
          setMessages((prev) => [...prev, payload.new as Message]);
        }
      )
      .subscribe();

    // クリーンアップ
    return () => {
      supabase.removeChannel(channel);
    };
  }, [conversationId]);

  return messages;
}

クリーンアップの重要性

開発日記にはこう書いています:

Realtime の接続管理: useEffect のクリーンアップでサブスクリプションを適切に解除

これは非常に重要なポイントです。クリーンアップを忘れると、コンポーネントがアンマウントされた後も購読が残り続け、メモリリークや意図しない状態更新が発生します。

return () => { supabase.removeChannel(channel); } を必ず記述しましょう。

RLS(Row Level Security)との連携

チャット機能では、「自分が参加している会話のメッセージだけ見れる」というセキュリティ要件があります。

開発日記から:

RLS は「デフォルト拒否」で設計し、必要な権限だけ付与するのが安全

メッセージの RLS ポリシー

-- メッセージの閲覧: 会話の参加者のみ
create policy "Users can view messages in their conversations"
on messages for select
using (
  exists (
    select 1 from conversation_participants
    where conversation_id = messages.conversation_id
    and user_id = auth.uid()
  )
);

-- メッセージの送信: 会話の参加者のみ、自分のIDで送信
create policy "Users can send messages to their conversations"
on messages for insert
with check (
  sender_id = auth.uid()
  and exists (
    select 1 from conversation_participants
    where conversation_id = messages.conversation_id
    and user_id = auth.uid()
  )
);

Realtime と RLS

Supabase Realtime は RLS を尊重します。つまり、ユーザーが閲覧権限を持たないメッセージは、Realtime で配信されません。

これは非常に便利な特性です。クライアント側でフィルタリングする必要がなく、セキュリティを Supabase に任せられます。

ブロック機能との連携

BandBridge では、Claude Code を使ってユーザーをブロックする機能も実装しました。

開発日記から:

ブロック機能(RLS でスカウト/メッセージ送信制限) ブロックの RLS 制限: API レベルで完全にブロック。クライアントサイドのみの制限は突破される可能性がある

ブロックされたユーザーからのメッセージは、RLS レベルで拒否します:

create policy "Blocked users cannot send messages"
on messages for insert
with check (
  not exists (
    select 1 from blocks
    where blocker_id = (
      select user_id from conversation_participants
      where conversation_id = messages.conversation_id
      and user_id != auth.uid()
      limit 1
    )
    and blocked_id = auth.uid()
  )
);

これにより、ブロックされたユーザーはメッセージを送信しようとしてもエラーになります。

実装で悩んだこと

接続の管理

開発日記にはこう書いています:

Realtime の接続管理: useEffect のクリーンアップでサブスクリプションを適切に解除

最初の実装では、チャットルームを切り替えたときに古い購読が残り、別のルームのメッセージが混ざる問題が発生しました。

解決策として、conversationIduseEffect の依存配列に含め、ID が変わるたびに古い購読を解除してから新しい購読を開始するようにしました。

初期メッセージの取得と Realtime の競合

もう一つ悩んだのは、初期データの取得と Realtime 購読のタイミングです。

  1. 初期メッセージを取得
  2. Realtime 購読を開始

この順序だと、1と2の間に新しいメッセージが投稿された場合、そのメッセージが漏れる可能性があります。

解決策として、Realtime 購読を先に開始し、その後で初期データを取得するようにしました:

useEffect(() => {
  // 先に Realtime 購読を開始
  const channel = supabase
    .channel(`messages:${conversationId}`)
    .on("postgres_changes", /* ... */)
    .subscribe();

  // その後で初期データを取得
  const fetchMessages = async () => {
    const { data } = await supabase
      .from("messages")
      .select("*")
      .eq("conversation_id", conversationId)
      .order("created_at", { ascending: true });

    if (data) setMessages(data);
  };

  fetchMessages();

  return () => {
    supabase.removeChannel(channel);
  };
}, [conversationId]);

パフォーマンス最適化

メッセージの増分取得

チャット履歴が長くなると、初期取得に時間がかかります。最新の N 件だけ取得し、スクロールで遡るときに追加取得する方式が有効です:

const fetchMessages = async (limit = 50, before?: string) => {
  let query = supabase
    .from("messages")
    .select("*")
    .eq("conversation_id", conversationId)
    .order("created_at", { ascending: false })
    .limit(limit);

  if (before) {
    query = query.lt("created_at", before);
  }

  const { data } = await query;
  return data?.reverse() || [];
};

楽観的更新

自分が送信したメッセージは、サーバーの応答を待たずに即座に画面に表示すると、UX が向上します:

const sendMessage = async (content: string) => {
  const optimisticMessage = {
    id: crypto.randomUUID(),
    conversation_id: conversationId,
    sender_id: currentUserId,
    content,
    created_at: new Date().toISOString(),
    _pending: true,
  };

  // 楽観的に追加
  setMessages((prev) => [...prev, optimisticMessage]);

  // 実際に送信
  const { error } = await supabase.from("messages").insert({
    conversation_id: conversationId,
    sender_id: currentUserId,
    content,
  });

  if (error) {
    // 失敗したら取り消し
    setMessages((prev) =>
      prev.filter((m) => m.id !== optimisticMessage.id)
    );
  }
};

まとめ

Supabase Realtime を使えば、WebSocket サーバーを自前で運用することなく、リアルタイムチャット機能を実装できます。

ポイントをまとめると:

  • postgres_changes モードでデータベース変更を購読
  • useEffect のクリーンアップで購読を適切に解除
  • RLS でセキュリティを担保(Realtime は RLS を尊重する)
  • ブロック機能も RLS で実装すると堅牢

BandBridge では、これらの知見をもとに Claude Code と協力して1日でチャット機能を実装できました。Supabase のオールインワンなアプローチは、個人開発の強い味方です。

チャット機能を作りたいと思っている方は、ぜひ Supabase Realtime を試してみてください。

Zeronova avatar

Zeronovaゼロノバ

Product Manager / AI-Native Builder

Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。

関連プロダクト

BandBridge(バンドブリッジ)

ミュージシャンとバンドをつなぐ