はじめに
リアルタイムチャットは、現代の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プロジェクトのセットアップ
- Supabaseでアカウントを作成し、新しいプロジェクトを作成
- 必要なパッケージをインストール
npm install @supabase/supabase-js
- 環境変数を設定
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.IO | Supabase |
|---|---|---|
| サーバー管理 | 自前でサーバーを用意する必要がある | マネージドサービスで管理不要 |
| スケーラビリティ | 自分でスケール設計が必要 | 自動でスケール |
| 永続化 | 別途データベースが必要 | PostgreSQLが組み込み |
| 認証 | 別途実装が必要 | 組み込みの認証機能 |
| カスタマイズ性 | 高い(完全に制御可能) | Supabaseの機能範囲内 |
| 料金 | サーバーコスト | 無料枠あり、従量課金 |
| ユースケース | ゲーム、低遅延が必要なアプリ | 一般的なチャット、リアルタイム通知 |
おすすめの選択
-
Socket.IOを選ぶべき場合:
- 極めて低遅延が必要(オンラインゲームなど)
- 複雑なイベント処理が必要
- 既存のNode.jsサーバーがある
-
Supabaseを選ぶべき場合:
- 素早くプロトタイプを作りたい
- サーバー管理を避けたい
- 認証機能も一緒に必要
- データの永続化が必要
セキュリティの考慮事項
リアルタイムチャットアプリでは、以下のセキュリティ対策が重要です。
XSS(クロスサイトスクリプティング)対策
// メッセージのサニタイズ例
function sanitizeMessage(content: string): string {
return content
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
レート制限
// 簡易的なレート制限の実装
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つのアプローチで解説しました。
ポイントのおさらい
- リアルタイム通信の基本: HTTPとWebSocketの違いを理解し、適切な技術を選択する
- Socket.IO: 双方向通信が可能で、細かい制御ができる。サーバー管理が必要
- Supabase: データベースと認証が統合されており、素早く開発できる。マネージドサービス
- セキュリティ: XSS対策やレート制限など、本番運用には必須
リアルタイム機能は、ユーザー体験を大きく向上させる強力な機能です。この記事を参考に、ぜひ自分のプロジェクトにリアルタイムチャット機能を実装してみてください。
参考文献
- Socket.IO Documentation - Socket.IO公式ドキュメント
- Supabase Realtime - Supabaseリアルタイム機能のガイド
- Next.js Documentation - Next.js公式ドキュメント
- WebSocket API - MDN Web Docs - WebSocketの仕様と使い方
- Building Real-time Applications with Next.js - Vercelのガイド