Next.js リアルタイムチャット:WebSocket と SSE の正しい選択

午前1時。PCの画面上で点滅するカーソルを見つめながら、私は3度目の挑戦をしていました。Next.js で WebSocket を動かそうとしていたのです。しかし画面には無情なエラーが:「WebSocket is not supported in this environment」。目をこすりました。ローカル環境では完璧に動いていたチャット機能が、Vercel にデプロイした途端に動かなくなったのです。
その夜、私は残酷な事実を悟りました。Next.js でのリアルタイム通信は、想像していたほど単純ではないと。
この記事では、無理やり WebSocket を Next.js にねじ込む方法(その道は行き止まりでした)ではなく、私が実際に経験した「泥臭い」運用ノウハウを共有します。なぜ Vercel は WebSocket を嫌うのか? SSE(Server-Sent Events)は救世主になり得るのか? Socket.io を App Router と共存させるには? もしあなたも Next.js のリアルタイム機能で頭を抱えているなら、この記事が近道になるはずです。
3つのリアルタイム通信方式を比較
チャットルーム、共同編集、リアルタイム通知。これらの機能は「サーバーからクライアントへ能動的にデータを送る」必要があります。従来の HTTP の「リクエスト-レスポンス」型では実現できないため、主に3つの解決策があります。
WebSocket:全二重通信の理想形
WebSocket は理想的です。一度接続を確立すれば、クライアントとサーバーがいつでも自由にメッセージを送り合えます。「これ一択でしょ?」と思いますよね。
私もそう思っていました。デプロイするまでは。
Vercel や Netlify などの Serverless プラットフォームは、長期間の持続的な接続をサポートしていません。Next.js アプリはクラウド関数(Function)として実行され、リクエスト処理が終われば即座に破棄されます。接続を維持する場所がないのです。Pusher や Ably などのサードパーティ WebSocket サービスを使えば解決しますが、月額料金を見てそっと閉じました。
もちろん、自分で VPS を借りて Node.js サーバーを立てれば WebSocket は使えます。コストとインフラ管理の手間を受け入れられるなら、ですが。
SSE (Server-Sent Events):単方向の救世主
SSE はその名の通り「サーバー送信イベント」です。単方向、つまりサーバーからクライアントへ送ることしかできません。
「え、チャットなのに一方通行?」と思うかもしれませんが、よく考えてください。チャット送信は HTTP POST で行い、受信だけ SSE で行えばいいのです。通知システムなら受信だけなのでさらに好相性です。
そして最大のメリット:Vercel 上で動作します。
以前作成した通知システムでは、SSE を採用して Vercel のデプロイ問題を解決しました。Vercel の Edge Function には 25秒(プランによってはもっと長い)のタイムアウトがありますが、適切に再接続処理を書けば実用レベルになります。
Long Polling:原始的だが確実
クライアントがリクエストを投げ、サーバーは新しいデータが来るまでレスポンスを保留する。データが来たら返し、クライアントは即座にまたリクエストを投げる。これがロングポーリングです。
効率は悪いです。サーバーリソースも食います。でも、ユーザーが数百人程度で、メッセージ頻度も低ければ、これで十分です。互換性の問題もなく、どのプラットフォームでも動きます。
「技術的負債」と笑うなかれ。小規模プロジェクトでは、これが最も安上がりで確実な解法になることも多いのです。
比較表:一目でわかる選び方
| 特性 | WebSocket | SSE | Long Polling |
|---|---|---|---|
| 双方向通信 | ✅ | ❌(POSTと併用が必要) | ✅ |
| Vercel対応 | ❌ | ✅(タイムアウト制限あり) | ✅ |
| ブラウザ互換 | 現代ブラウザ | 現代ブラウザ | 全て |
| 接続負荷 | 低 | 低 | 高 |
| 実装複雑度 | 中 | 低 | 極低 |
| 適合シーン | チャット、ゲーム、共同編集 | 通知、フィード更新 | 低頻度更新、高互換性要件 |
お気づきの通り、Next.js + Vercel の組み合わせでは、SSE か Long Polling が現実的な選択肢となります。
Next.js 環境での選定ガイド
方式はわかりましたが、どれを選ぶべきか? 私の失敗談に基づいた判断基準はこちらです。
1. デプロイ先で決める
Vercel, Netlify, Cloudflare Pages 等の Serverless 環境を使うなら、WebSocket は諦めてください(またはサードパーティサービスを使う)。
選択肢は3つ:
- SSE: 通知、チャット受信などのプッシュ通信向け。
- Long Polling: 低頻度の双方向通信向け。
- WebSocket サーバーの分離: Next.js は Vercel に置き、WebSocket 部分だけ安い VPS で動かす。
私は通常、リアルタイム性が極めて高い(共同編集など)場合を除き、SSE を採用しています。
2. ユーザー規模で決める
同時接続数は?
- < 100人: Long Polling で十分。実装1時間で終わります。
- 100-1000人: SSE、またはシンプルな WebSocket。
- > 1000人: Redis Pub/Sub、負荷分散、多インスタンス構成が必要。
あるスタートアップでは、初期は Long Polling で凌ぎ、ユーザーが500人を超えたあたりで SSE にリファクタリングしました。初期段階から過剰なアーキテクチャを組む必要はありません。
3. メッセージ頻度で決める
分単位の更新なら Long Polling。秒単位で飛び交うチャットなら SSE か WebSocket。
注意点として、**ブラウザの同一ドメイン HTTP 接続数制限(通常6個)**があります。SSE や Long Polling を使うタブを複数開くと、他のリクエストが詰まることがあります(HTTP/2 なら緩和されますが)。
私の決定ツリー
デプロイ先は Vercel?
├─ YES → ユーザー > 1000人?
│ ├─ YES → SSE + Redis Pub/Sub
│ └─ NO → 単純な SSE or Long Polling
└─ NO (VPS等) → 双方向・高頻度?
├─ YES → WebSocket + Socket.io
└─ NO → SSESocket.io 統合実践(VPS/Custom Server編)
もしあなたが VPS へのデプロイを選び、WebSocket を使うなら、Socket.io が鉄板です。自動再接続や部屋管理機能が強力です。
ただし、Next.js App Router と組み合わせるには Custom Server が必要です。
手順1: Custom Server の作成
プロジェクトルートに server.js を作成します:
// server.js
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const { Server } = require('socket.io');
const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = 3000;
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const httpServer = createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
});
// Socket.io 初期化
const io = new Server(httpServer, {
cors: {
origin: dev ? 'http://localhost:3000' : 'https://yourdomain.com',
methods: ['GET', 'POST']
}
});
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
// ルーム参加
socket.on('join_room', (roomId) => {
socket.join(roomId);
});
// メッセージ受信&ブロードキャスト
socket.on('send_message', (data) => {
io.to(data.room).emit('receive_message', {
id: Date.now(),
...data,
timestamp: new Date().toISOString()
});
});
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
});
});
httpServer.listen(port, (err) => {
if (err) throw err;
console.log(`> Ready on http://${hostname}:${port}`);
});
});package.json の書き換えも必要です:
"scripts": {
"dev": "node server.js",
"build": "next build",
"start": "NODE_ENV=production node server.js"
}手順2: クライアントフック
// hooks/useSocket.ts
'use client';
import { useEffect, useState } from 'react';
import io, { Socket } from 'socket.io-client';
export function useSocket() {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const socketInstance = io('http://localhost:3000', {
transports: ['websocket', 'polling'] // WebSocket優先
});
socketInstance.on('connect', () => setIsConnected(true));
socketInstance.on('disconnect', () => setIsConnected(false));
setSocket(socketInstance);
return () => {
socketInstance.disconnect();
};
}, []);
return { socket, isConnected };
}注意点:
- Vercel デプロイ不可: Custom Server は Vercel では動きません。
- 型定義:
npm i -D @types/socket.io-clientを忘れずに。
SSE (Server-Sent Events) 実装(Vercel 編)
私のほとんどのプロジェクト(Vercel デプロイ)ではこちらを使います。Custom Server 不要、標準の Route Handler で実装可能です。
サーバーサイド実装 (App Router)
// app/api/sse/route.ts
import { NextRequest } from 'next/server';
// 簡易的なメッセージキュー(本番では Redis 推奨)
const listeners = new Set<(message: any) => void>();
export async function GET(request: NextRequest) {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// 初期メッセージ
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
);
const listener = (message: any) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(message)}\n\n`)
);
};
listeners.add(listener);
// ハートビート(接続維持用)
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
}, 15000);
// 切断時のクリーンアップ
request.signal.addEventListener('abort', () => {
listeners.delete(listener);
clearInterval(heartbeat);
controller.close();
});
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
}
// 送信エンドポイント(POST)
export async function POST(request: NextRequest) {
const body = await request.json();
const message = { ...body, timestamp: new Date().toISOString() };
// 全リスナーに通知
listeners.forEach(listener => listener(message));
return Response.json({ success: true });
}クライアントサイド実装
// app/sse-chat/page.tsx
'use client';
import { useEffect, useState } from 'react';
export default function SSEChatPage() {
const [messages, setMessages] = useState<any[]>([]);
useEffect(() => {
const eventSource = new EventSource('/api/sse');
eventSource.onopen = () => console.log('SSE Connected');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'connected') return;
setMessages(prev => [...prev, data]);
};
eventSource.onerror = () => {
console.log('SSE Error, reconnecting...');
eventSource.close();
// ブラウザ標準の再接続メカニズムに任せるか、自前でリトライ
};
return () => eventSource.close();
}, []);
// sendMessage 関数は fetch('/api/sse', { method: 'POST' ... }) を呼ぶだけ
// ...
}Vercel での SSE 戦略:Redis 必須
上記のコードは単一インスタンスなら動きますが、Vercel のような Serverless 環境は複数のインスタンスが立ち上がります。インスタンス A に接続しているユーザーの投稿が、インスタンス B に接続しているユーザーに見えない、という問題が起きます。
解決策は Redis Pub/Sub です(Upstash がおすすめ)。
- ユーザーがメッセージ送信(POST)
- API がメッセージを Redis に Publish
- 各 SSE インスタンスが Redis を Subscribe しており、メッセージを受信
- 各 SSE インスタンスが接続中のクライアントにプッシュ
これで全ユーザーにメッセージが届きます。コストはかかりますが、WebSocket サービスよりは遥かに安いです。
メッセージ状態管理:「送った感」を演出する
通信方式以上に重要なのが、UX(ユーザー体験)です。「送信中…」「失敗」「再送」のステータス管理は必須です。
楽観的更新 (Optimistic UI)
サーバーからの返事を待ってから表示するのでは遅すぎます。「送信ボタンを押した瞬間、チャット欄に表示する」。これが正解です。
// hooks/useChat.ts
const sendMessage = async (text: string) => {
const tempId = `local_${Date.now()}`;
// 1. 即座にUI更新(status: sending)
const tempMsg = {
id: tempId,
text,
status: 'sending'
};
setMessages(prev => [...prev, tempMsg]);
try {
// 2. サーバー送信
const res = await fetch('/api/messages', { ... });
const data = await res.json();
// 3. 成功したらIDを正式なものに置換、status: sent
setMessages(prev => prev.map(m =>
m.id === tempId ? { ...m, id: data.id, status: 'sent' } : m
));
} catch (err) {
// 4. 失敗したら status: failed に
setMessages(prev => prev.map(m =>
m.id === tempId ? { ...m, status: 'failed' } : m
));
}
};IndexedDB によるオフライン耐久性
電車でトンネルに入り、ネットが切れる。送信ボタンを押す。ネットが復帰する。あのメッセージはどうなる?
消えてしまったら最悪です。私は IndexedDB を使って、送信中のメッセージをローカルに永続化しています。アプリ起動時に IndexedDB をチェックし、未送信メッセージがあれば自動で再送キューに入れます。これで「地下鉄でも安心」なチャットアプリになります。
まとめ
Next.js でのリアルタイムチャット実装は、デプロイ環境との戦いです。
- Vercel なら: SSE + Redis Pub/Sub が最適解。
- VPS なら: Custom Server + Socket.io が機能豊富。
- 小規模なら: Long Polling を恥ずかしがらずに使う。
そして、通信方式と同じくらい**UX(楽観的更新、オフライン対応)**を作り込んでください。ユーザーは技術スタックなんて気にしません。「サクサク動いて、メッセージが消えない」ことだけが評価基準ですから。
FAQ
Vercel で WebSocket は絶対に使えないのですか?
SSE と WebSocket、スマホのバッテリー消費はどうですか?
Socket.io を使えば Vercel でも自動で Long Polling に落ちて動くのでは?
5 min read · 公開日: 2026年1月7日 · 更新日: 2026年1月22日
関連記事
Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践

Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践
Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド

Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド
Next.js ユニットテスト実践:Jest + React Testing Library 完全設定ガイド


コメント
GitHubアカウントでログインしてコメントできます