Documentation Next.js

はじめに

リアルタイムチャットは、現代のWebアプリケーションにおいて最も求められる機能の一つです。LINEやSlackのように、メッセージを送信した瞬間に相手の画面に表示される体験は、ユーザーにとって当たり前のものとなっています。

この記事では、Next.jsを使ってリアルタイムチャットアプリを構築する方法を解説します。リアルタイム通信を実現する2つの主要な技術「Socket.IO」と「Supabase」の両方のアプローチを紹介し、それぞれの特徴や実装方法を詳しく説明します。

この記事で学べること

  • リアルタイム通信の基本概念(WebSocket、ポーリングなど)
  • Socket.IOを使った双方向通信の実装
  • Supabaseのリアルタイム機能を活用したチャットの実装
  • ユーザー認証とメッセージ管理の統合
  • 実用的なチャットUIの作成

前提知識

  • React/Next.jsの基本的な知識
  • TypeScriptの基本的な理解
  • Node.jsの基本的な操作

リアルタイム通信の基本概念

HTTPとWebSocketの違い

従来のHTTP通信では、クライアントがサーバーにリクエストを送り、サーバーがレスポンスを返すという「一方向の通信」が基本です。しかし、チャットアプリでは「サーバーからクライアントへ」の通信も必要になります。

【HTTP通信の流れ】
クライアント → リクエスト → サーバー
クライアント ← レスポンス ← サーバー
※サーバーから自発的に送信できない

【WebSocket通信の流れ】
クライアント ⇔ 双方向通信 ⇔ サーバー
※どちらからでも送信可能

リアルタイム通信を実現する方法

方式説明メリットデメリット
ポーリング定期的にサーバーに問い合わせる実装が簡単サーバー負荷が高い
ロングポーリングサーバーがデータがあるまで接続を保持即時性が向上コネクション管理が複雑
WebSocket双方向の永続的な接続リアルタイム性が高いサーバー側の対応が必要
Server-Sent Eventsサーバーからクライアントへの一方向通信実装が比較的簡単クライアントからの送信は別途HTTP

必要な技術スタックの概要

Socket.IOとは

Socket.IOは、WebSocketをベースにしたリアルタイム双方向通信ライブラリです。WebSocketが使えない環境では自動的にポーリングにフォールバックするため、幅広い環境で安定した通信が可能です。

主な特徴:

  • 自動再接続機能
  • ルーム機能(グループチャット向け)
  • イベントベースの通信
  • バイナリデータのサポート

Supabaseとは

Supabaseは、オープンソースのFirebase代替サービスです。PostgreSQLデータベースをベースに、リアルタイム機能、認証、ストレージなどを提供するBaaS(Backend as a Service)です。

主な特徴:

  • PostgreSQLの変更をリアルタイムで検知
  • 組み込みの認証機能
  • Row Level Security(行レベルセキュリティ)
  • RESTful APIの自動生成

Socket.IOを使った実装

プロジェクトのセットアップ

まず、必要なパッケージをインストールします。

# Next.jsプロジェクトの作成(まだの場合)
npx create-next-app@latest chat-app --typescript

# Socket.IO関連パッケージのインストール
npm install socket.io socket.io-client

サーバーサイドの実装

Next.jsのAPI RoutesでSocket.IOサーバーを設定します。

import { Server as HTTPServer } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import type { NextApiRequest, NextApiResponse } from 'next';
import type { Socket as NetSocket } from 'net';

// 拡張された型定義
interface SocketServer extends HTTPServer {
  io?: SocketIOServer;
}

interface SocketWithIO extends NetSocket {
  server: SocketServer;
}

interface NextApiResponseWithSocket extends NextApiResponse {
  socket: SocketWithIO;
}

// メッセージの型定義
interface ChatMessage {
  id: string;
  content: string;
  userName: string;
  timestamp: Date;
  roomId: string;
}

export default function handler(
  req: NextApiRequest,
  res: NextApiResponseWithSocket
) {
  // Socket.IOサーバーが既に初期化されているかチェック
  if (res.socket.server.io) {
    console.log('Socket.IO server already running');
    res.end();
    return;
  }

  console.log('Initializing Socket.IO server...');

  // Socket.IOサーバーの初期化
  const io = new SocketIOServer(res.socket.server, {
    path: '/api/socket',
    // CORSの設定(開発環境用)
    cors: {
      origin: process.env.NODE_ENV === 'development'
        ? 'http://localhost:3000'
        : process.env.NEXT_PUBLIC_APP_URL,
      methods: ['GET', 'POST'],
    },
  });

  // 接続イベントのハンドリング
  io.on('connection', (socket) => {
    console.log(`Client connected: ${socket.id}`);

    // ルームへの参加
    socket.on('join-room', (roomId: string) => {
      socket.join(roomId);
      console.log(`${socket.id} joined room: ${roomId}`);

      // ルーム内の他のユーザーに通知
      socket.to(roomId).emit('user-joined', {
        userId: socket.id,
        message: '新しいユーザーが参加しました',
      });
    });

    // メッセージの受信と配信
    socket.on('send-message', (message: ChatMessage) => {
      console.log(`Message received: ${message.content}`);

      // 同じルームの全員にメッセージを送信(送信者含む)
      io.to(message.roomId).emit('receive-message', {
        ...message,
        timestamp: new Date(),
      });
    });

    // タイピング中の通知
    socket.on('typing', ({ roomId, userName }: { roomId: string; userName: string }) => {
      // 送信者以外のルームメンバーに通知
      socket.to(roomId).emit('user-typing', { userName });
    });

    // 切断時の処理
    socket.on('disconnect', () => {
      console.log(`Client disconnected: ${socket.id}`);
    });
  });

  res.socket.server.io = io;
  res.end();
}

// WebSocket用にbodyParserを無効化
export const config = {
  api: {
    bodyParser: false,
  },
};

クライアントサイドの実装

チャット機能を持つReactコンポーネントを作成します。

import { useEffect, useState, useCallback } from 'react';
import io, { Socket } from 'socket.io-client';

// メッセージの型定義
export interface ChatMessage {
  id: string;
  content: string;
  userName: string;
  timestamp: Date;
  roomId: string;
}

// フックの戻り値の型定義
interface UseSocketReturn {
  socket: Socket | null;
  isConnected: boolean;
  messages: ChatMessage[];
  typingUser: string | null;
  sendMessage: (content: string) => void;
  setTyping: () => void;
}

export function useSocket(roomId: string, userName: string): UseSocketReturn {
  const [socket, setSocket] = useState<Socket | null>(null);
  const [isConnected, setIsConnected] = useState(false);
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [typingUser, setTypingUser] = useState<string | null>(null);

  useEffect(() => {
    // Socket.IOサーバーへの接続を初期化
    const initSocket = async () => {
      // APIルートを呼び出してSocket.IOサーバーを起動
      await fetch('/api/socket');

      // クライアント側のソケット接続
      const newSocket = io({
        path: '/api/socket',
        // 接続オプション
        transports: ['websocket', 'polling'],
      });

      // 接続成功時
      newSocket.on('connect', () => {
        console.log('Connected to Socket.IO server');
        setIsConnected(true);

        // ルームに参加
        newSocket.emit('join-room', roomId);
      });

      // 接続エラー時
      newSocket.on('connect_error', (error) => {
        console.error('Connection error:', error);
        setIsConnected(false);
      });

      // メッセージ受信時
      newSocket.on('receive-message', (message: ChatMessage) => {
        setMessages((prev) => [...prev, message]);
      });

      // タイピング通知受信時
      newSocket.on('user-typing', ({ userName }: { userName: string }) => {
        setTypingUser(userName);
        // 3秒後にタイピング表示を消す
        setTimeout(() => setTypingUser(null), 3000);
      });

      // 切断時
      newSocket.on('disconnect', () => {
        console.log('Disconnected from Socket.IO server');
        setIsConnected(false);
      });

      setSocket(newSocket);
    };

    initSocket();

    // クリーンアップ関数
    return () => {
      if (socket) {
        socket.disconnect();
      }
    };
  }, [roomId]); // roomIdが変わったら再接続

  // メッセージ送信関数
  const sendMessage = useCallback(
    (content: string) => {
      if (!socket || !content.trim()) return;

      const message: ChatMessage = {
        id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
        content: content.trim(),
        userName,
        timestamp: new Date(),
        roomId,
      };

      socket.emit('send-message', message);
    },
    [socket, userName, roomId]
  );

  // タイピング中を通知する関数
  const setTyping = useCallback(() => {
    if (!socket) return;
    socket.emit('typing', { roomId, userName });
  }, [socket, roomId, userName]);

  return {
    socket,
    isConnected,
    messages,
    typingUser,
    sendMessage,
    setTyping,
  };
}
import { useState, useRef, useEffect, FormEvent, ChangeEvent } from 'react';
import { useSocket, ChatMessage } from '../hooks/useSocket';

interface ChatProps {
  roomId: string;
  userName: string;
}

export default function Chat({ roomId, userName }: ChatProps) {
  const [inputMessage, setInputMessage] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);

  // カスタムフックでSocket.IO機能を利用
  const { isConnected, messages, typingUser, sendMessage, setTyping } = useSocket(
    roomId,
    userName
  );

  // 新しいメッセージが来たら自動スクロール
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  // フォーム送信時の処理
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (inputMessage.trim()) {
      sendMessage(inputMessage);
      setInputMessage('');
    }
  };

  // 入力中の処理(タイピング通知)
  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    setInputMessage(e.target.value);
    setTyping(); // タイピング中であることを通知
  };

  // 日時のフォーマット
  const formatTime = (date: Date) => {
    return new Date(date).toLocaleTimeString('ja-JP', {
      hour: '2-digit',
      minute: '2-digit',
    });
  };

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto">
      {/* ヘッダー */}
      <header className="bg-blue-600 text-white p-4 shadow-md">
        <h1 className="text-xl font-bold">チャットルーム: {roomId}</h1>
        <div className="flex items-center gap-2 text-sm">
          <span
            className={`w-2 h-2 rounded-full ${
              isConnected ? 'bg-green-400' : 'bg-red-400'
            }`}
          />
          <span>{isConnected ? '接続中' : '切断中'}</span>
        </div>
      </header>

      {/* メッセージ一覧 */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-100">
        {messages.length === 0 ? (
          <p className="text-center text-gray-500">
            メッセージはまだありません最初のメッセージを送信しましょう
          </p>
        ) : (
          messages.map((msg: ChatMessage) => (
            <div
              key={msg.id}
              className={`flex ${
                msg.userName === userName ? 'justify-end' : 'justify-start'
              }`}
            >
              <div
                className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
                  msg.userName === userName
                    ? 'bg-blue-500 text-white'
                    : 'bg-white text-gray-800 shadow'
                }`}
              >
                {/* 他のユーザーのメッセージには名前を表示 */}
                {msg.userName !== userName && (
                  <p className="text-xs font-semibold text-gray-600 mb-1">
                    {msg.userName}
                  </p>
                )}
                <p className="break-words">{msg.content}</p>
                <p
                  className={`text-xs mt-1 ${
                    msg.userName === userName ? 'text-blue-100' : 'text-gray-400'
                  }`}
                >
                  {formatTime(msg.timestamp)}
                </p>
              </div>
            </div>
          ))
        )}

        {/* タイピング表示 */}
        {typingUser && typingUser !== userName && (
          <p className="text-sm text-gray-500 italic">
            {typingUser}が入力中...
          </p>
        )}

        {/* 自動スクロール用の要素 */}
        <div ref={messagesEndRef} />
      </div>

      {/* メッセージ入力フォーム */}
      <form
        onSubmit={handleSubmit}
        className="p-4 bg-white border-t flex gap-2"
      >
        <input
          type="text"
          value={inputMessage}
          onChange={handleInputChange}
          placeholder="メッセージを入力..."
          disabled={!isConnected}
          className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
        />
        <button
          type="submit"
          disabled={!isConnected || !inputMessage.trim()}
          className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
        >
          送信
        </button>
      </form>
    </div>
  );
}

Supabaseを使った実装

Supabaseプロジェクトのセットアップ

  1. Supabaseでアカウントを作成し、新しいプロジェクトを作成
  2. 必要なパッケージをインストール
npm install @supabase/supabase-js
  1. 環境変数を設定
NEXT_PUBLIC_SUPABASE_URL=your-supabase-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

データベーススキーマの作成

Supabaseのダッシュボード(SQL Editor)で以下のSQLを実行します。

-- メッセージテーブルの作成
CREATE TABLE messages (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  content TEXT NOT NULL,
  user_id UUID REFERENCES auth.users(id),
  user_name TEXT NOT NULL,
  room_id TEXT NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- リアルタイム機能を有効化
ALTER TABLE messages REPLICA IDENTITY FULL;

-- Row Level Security (RLS) を有効化
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

-- 読み取りポリシー: 認証されたユーザーは全メッセージを読める
CREATE POLICY "Users can read all messages"
  ON messages
  FOR SELECT
  TO authenticated
  USING (true);

-- 挿入ポリシー: 認証されたユーザーは自分のメッセージを投稿できる
CREATE POLICY "Users can insert their own messages"
  ON messages
  FOR INSERT
  TO authenticated
  WITH CHECK (auth.uid() = user_id);

-- インデックスの作成(パフォーマンス向上)
CREATE INDEX idx_messages_room_id ON messages(room_id);
CREATE INDEX idx_messages_created_at ON messages(created_at DESC);

Supabaseクライアントの設定

import { createClient } from '@supabase/supabase-js';

// 型定義
export interface Message {
  id: string;
  content: string;
  user_id: string;
  user_name: string;
  room_id: string;
  created_at: string;
}

export interface Database {
  public: {
    Tables: {
      messages: {
        Row: Message;
        Insert: Omit<Message, 'id' | 'created_at'>;
        Update: Partial<Omit<Message, 'id'>>;
      };
    };
  };
}

// 環境変数のチェック
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;

if (!supabaseUrl || !supabaseAnonKey) {
  throw new Error('Missing Supabase environment variables');
}

// Supabaseクライアントの作成
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
  realtime: {
    params: {
      eventsPerSecond: 10, // レート制限
    },
  },
});

リアルタイムチャットコンポーネント

import { useEffect, useState, FormEvent, useCallback } from 'react';
import { supabase, Message } from '../lib/supabase';
import { RealtimeChannel, User } from '@supabase/supabase-js';

interface SupabaseChatProps {
  roomId: string;
}

export default function SupabaseChat({ roomId }: SupabaseChatProps) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [newMessage, setNewMessage] = useState('');
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // ユーザー認証状態の監視
  useEffect(() => {
    // 現在のセッションを取得
    const getSession = async () => {
      const { data: { session } } = await supabase.auth.getSession();
      setUser(session?.user ?? null);
    };

    getSession();

    // 認証状態の変更を監視
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (_event, session) => {
        setUser(session?.user ?? null);
      }
    );

    return () => {
      subscription.unsubscribe();
    };
  }, []);

  // メッセージの初期読み込みとリアルタイム購読
  useEffect(() => {
    let channel: RealtimeChannel;

    const setupChat = async () => {
      setIsLoading(true);
      setError(null);

      try {
        // 既存のメッセージを取得
        const { data, error: fetchError } = await supabase
          .from('messages')
          .select('*')
          .eq('room_id', roomId)
          .order('created_at', { ascending: true })
          .limit(100); // 最新100件を取得

        if (fetchError) throw fetchError;

        setMessages(data || []);

        // リアルタイム購読の設定
        channel = supabase
          .channel(`room:${roomId}`)
          .on(
            'postgres_changes',
            {
              event: 'INSERT', // 新規メッセージのみ監視
              schema: 'public',
              table: 'messages',
              filter: `room_id=eq.${roomId}`,
            },
            (payload) => {
              // 新しいメッセージを追加
              const newMsg = payload.new as Message;
              setMessages((prev) => [...prev, newMsg]);
            }
          )
          .subscribe((status) => {
            if (status === 'SUBSCRIBED') {
              console.log(`Subscribed to room: ${roomId}`);
            }
            if (status === 'CHANNEL_ERROR') {
              setError('リアルタイム接続に失敗しました');
            }
          });

      } catch (err) {
        console.error('Error setting up chat:', err);
        setError('チャットの初期化に失敗しました');
      } finally {
        setIsLoading(false);
      }
    };

    setupChat();

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

  // メッセージ送信
  const handleSendMessage = useCallback(
    async (e: FormEvent) => {
      e.preventDefault();

      if (!newMessage.trim() || !user) return;

      const messageContent = newMessage.trim();
      setNewMessage(''); // 先にクリア(UX向上)

      try {
        const { error: insertError } = await supabase.from('messages').insert({
          content: messageContent,
          user_id: user.id,
          user_name: user.email?.split('@')[0] || 'Anonymous',
          room_id: roomId,
        });

        if (insertError) throw insertError;
      } catch (err) {
        console.error('Error sending message:', err);
        setError('メッセージの送信に失敗しました');
        setNewMessage(messageContent); // 失敗時は入力を復元
      }
    },
    [newMessage, user, roomId]
  );

  // ログイン処理
  const handleLogin = async () => {
    try {
      const { error } = await supabase.auth.signInWithOAuth({
        provider: 'github', // GitHubログインの例
        options: {
          redirectTo: window.location.origin,
        },
      });
      if (error) throw error;
    } catch (err) {
      console.error('Login error:', err);
      setError('ログインに失敗しました');
    }
  };

  // ログアウト処理
  const handleLogout = async () => {
    await supabase.auth.signOut();
  };

  // 日時フォーマット
  const formatTime = (dateString: string) => {
    return new Date(dateString).toLocaleTimeString('ja-JP', {
      hour: '2-digit',
      minute: '2-digit',
    });
  };

  // 未ログイン時の表示
  if (!user) {
    return (
      <div className="flex flex-col items-center justify-center h-screen bg-gray-100">
        <div className="bg-white p-8 rounded-lg shadow-md text-center">
          <h1 className="text-2xl font-bold mb-4">チャットに参加</h1>
          <p className="text-gray-600 mb-6">
            チャットに参加するにはログインが必要です
          </p>
          <button
            onClick={handleLogin}
            className="px-6 py-3 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-2 mx-auto"
          >
            <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
              <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
            </svg>
            GitHubでログイン
          </button>
        </div>
      </div>
    );
  }

  // ローディング中の表示
  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-screen">
        <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
      </div>
    );
  }

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto">
      {/* ヘッダー */}
      <header className="bg-green-600 text-white p-4 shadow-md">
        <div className="flex justify-between items-center">
          <div>
            <h1 className="text-xl font-bold">ルーム: {roomId}</h1>
            <p className="text-sm text-green-100">
              ログイン中: {user.email}
            </p>
          </div>
          <button
            onClick={handleLogout}
            className="px-4 py-2 bg-green-700 rounded hover:bg-green-800 transition-colors"
          >
            ログアウト
          </button>
        </div>
      </header>

      {/* エラー表示 */}
      {error && (
        <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3">
          {error}
          <button
            onClick={() => setError(null)}
            className="float-right font-bold"
          >
            ×
          </button>
        </div>
      )}

      {/* メッセージ一覧 */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
        {messages.map((msg) => (
          <div
            key={msg.id}
            className={`flex ${
              msg.user_id === user.id ? 'justify-end' : 'justify-start'
            }`}
          >
            <div
              className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
                msg.user_id === user.id
                  ? 'bg-green-500 text-white'
                  : 'bg-white text-gray-800 shadow'
              }`}
            >
              {msg.user_id !== user.id && (
                <p className="text-xs font-semibold text-gray-600 mb-1">
                  {msg.user_name}
                </p>
              )}
              <p className="break-words">{msg.content}</p>
              <p
                className={`text-xs mt-1 ${
                  msg.user_id === user.id ? 'text-green-100' : 'text-gray-400'
                }`}
              >
                {formatTime(msg.created_at)}
              </p>
            </div>
          </div>
        ))}
      </div>

      {/* メッセージ入力 */}
      <form
        onSubmit={handleSendMessage}
        className="p-4 bg-white border-t flex gap-2"
      >
        <input
          type="text"
          value={newMessage}
          onChange={(e) => setNewMessage(e.target.value)}
          placeholder="メッセージを入力..."
          className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
        />
        <button
          type="submit"
          disabled={!newMessage.trim()}
          className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
        >
          送信
        </button>
      </form>
    </div>
  );
}

Socket.IOとSupabaseの比較

選択の指針

観点Socket.IOSupabase
サーバー管理自前でサーバーを用意する必要があるマネージドサービスで管理不要
スケーラビリティ自分でスケール設計が必要自動でスケール
永続化別途データベースが必要PostgreSQLが組み込み
認証別途実装が必要組み込みの認証機能
カスタマイズ性高い(完全に制御可能)Supabaseの機能範囲内
料金サーバーコスト無料枠あり、従量課金
ユースケースゲーム、低遅延が必要なアプリ一般的なチャット、リアルタイム通知

おすすめの選択

  • Socket.IOを選ぶべき場合:

    • 極めて低遅延が必要(オンラインゲームなど)
    • 複雑なイベント処理が必要
    • 既存のNode.jsサーバーがある
  • Supabaseを選ぶべき場合:

    • 素早くプロトタイプを作りたい
    • サーバー管理を避けたい
    • 認証機能も一緒に必要
    • データの永続化が必要

セキュリティの考慮事項

リアルタイムチャットアプリでは、以下のセキュリティ対策が重要です。

XSS(クロスサイトスクリプティング)対策

// メッセージのサニタイズ例
function sanitizeMessage(content: string): string {
  return content
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

レート制限

// 簡易的なレート制限の実装
const messageRateLimit = new Map<string, number[]>();
const RATE_LIMIT_WINDOW = 60000; // 1分
const MAX_MESSAGES_PER_WINDOW = 30; // 1分間に30メッセージまで

function checkRateLimit(userId: string): boolean {
  const now = Date.now();
  const userMessages = messageRateLimit.get(userId) || [];

  // 古いメッセージを削除
  const recentMessages = userMessages.filter(
    (timestamp) => now - timestamp < RATE_LIMIT_WINDOW
  );

  if (recentMessages.length >= MAX_MESSAGES_PER_WINDOW) {
    return false; // レート制限に引っかかった
  }

  recentMessages.push(now);
  messageRateLimit.set(userId, recentMessages);
  return true;
}

まとめ

この記事では、Next.jsを使ったリアルタイムチャットアプリの実装方法を、Socket.IOとSupabaseの2つのアプローチで解説しました。

ポイントのおさらい

  1. リアルタイム通信の基本: HTTPとWebSocketの違いを理解し、適切な技術を選択する
  2. Socket.IO: 双方向通信が可能で、細かい制御ができる。サーバー管理が必要
  3. Supabase: データベースと認証が統合されており、素早く開発できる。マネージドサービス
  4. セキュリティ: XSS対策やレート制限など、本番運用には必須

リアルタイム機能は、ユーザー体験を大きく向上させる強力な機能です。この記事を参考に、ぜひ自分のプロジェクトにリアルタイムチャット機能を実装してみてください。

参考文献

円