言語を切り替える
テーマを切り替える

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 接続コスト
ハンドシェイク1回で持続接続
SSE 実装難易度
ブラウザ標準の EventSource API で完結
最強
Long Polling 互換性
どんな環境でも動く
SSE 推奨
Vercel 親和性
WebSocket は原則不可
特性WebSocketSSELong Polling
双方向通信❌(POSTと併用が必要)
Vercel対応✅(タイムアウト制限あり)
ブラウザ互換現代ブラウザ現代ブラウザ全て
接続負荷
実装複雑度極低
適合シーンチャット、ゲーム、共同編集通知、フィード更新低頻度更新、高互換性要件

お気づきの通り、Next.js + Vercel の組み合わせでは、SSE か Long Polling が現実的な選択肢となります。

Next.js 環境での選定ガイド

方式はわかりましたが、どれを選ぶべきか? 私の失敗談に基づいた判断基準はこちらです。

1. デプロイ先で決める

Vercel, Netlify, Cloudflare Pages 等の Serverless 環境を使うなら、WebSocket は諦めてください(またはサードパーティサービスを使う)。
選択肢は3つ:

  1. SSE: 通知、チャット受信などのプッシュ通信向け。
  2. Long Polling: 低頻度の双方向通信向け。
  3. 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  → SSE

Socket.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 };
}

注意点:

  1. Vercel デプロイ不可: Custom Server は Vercel では動きません。
  2. 型定義: 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 がおすすめ)。

  1. ユーザーがメッセージ送信(POST)
  2. API がメッセージを Redis に Publish
  3. 各 SSE インスタンスが Redis を Subscribe しており、メッセージを受信
  4. 各 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 は絶対に使えないのですか?
Next.js アプリ自体を Vercel (Serverless Functions) で動かす場合、WebSocket サーバーのホスティングは不可能です。ただし、Pusher や Ably などの外部 WebSocket サービスをクライアントから利用することは可能です。自前でホストしたい場合は、WebSocket サーバー部分だけを Railway や VPS に切り出す必要があります。
SSE と WebSocket、スマホのバッテリー消費はどうですか?
一般的に WebSocket の方が接続維持のオーバーヘッドが少なく、バッテリー効率が良いとされています。SSE(特に頻繁なポーリングフォールバックが発生する場合)はやや消費が増える傾向にありますが、近年のスマホでは実用上そこまで大きな差にはなりません。
Socket.io を使えば Vercel でも自動で Long Polling に落ちて動くのでは?
Socket.io クライアントは自動で Long Polling を試みますが、Vercel の Serverless Function はステートレス(状態を持たない)であるため、連続したポーリングリクエストが同じインスタンスに届く保証がなく、ハンドシェイクが成立せず失敗することが多いです。Vercel 上での Socket.io 運用は推奨されません。

5 min read · 公開日: 2026年1月7日 · 更新日: 2026年1月22日

コメント

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

関連記事