Socket.io が Next.js App Router で動かない・接続できない時の解決法【WebSocket / カスタムサーバー】
公開日:2026-07-28/カテゴリ:Socket.io・Next.js・WebSocket・フルスタック/AnchorUp 技術ブログ
Next.js を App Router に移行した途端、Socket.io のリアルタイム機能が突然動かなくなった——そういう報告が多い。接続がタイムアウトする、xhr poll error が出続ける、あるいは何のエラーも出ずに静かに失敗する。原因は App Router のアーキテクチャにある。本記事では原因を構造から理解し、実務で使える3つの解決策を紹介する。
なぜ App Router で Socket.io が動かないのか
結論:App Router の Route Handlers は WebSocket 非対応。これが根本原因だ。
Next.js の Pages Router 時代は、pages/api/socket.ts に独自の Node.js http.Server を注入して Socket.io を初期化する手法が広く使われていた。
// pages/api/socket.ts(Pages Router での旧来の手法)
import { Server } from 'socket.io'
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if ((res.socket as any).server.io) {
res.end()
return
}
const io = new Server((res.socket as any).server)
;(res.socket as any).server.io = io
io.on('connection', (socket) => {
socket.on('message', (data) => {
io.emit('message', data)
})
})
res.end()
}
この手法は App Router では動作しない。理由は2つある。
理由1:Route Handlers はステートレス HTTP レスポンスしか返せない
App Router の app/api/route.ts は Request を受け取り Response を返す設計だ。これは Web 標準の Fetch API に準拠しており、持続的な TCP 接続(WebSocket)を扱う仕組みがない。
// app/api/socket/route.ts — これは動かない
import { Server } from 'socket.io'
import { NextResponse } from 'next/server'
export async function GET(req: Request) {
// ❌ res.socket が存在しない。Web API の Request に Node.js の
// socket オブジェクトはない
const io = new Server(/* ? */)
return NextResponse.json({ ok: true })
}
理由2:Node.js ランタイム依存の API が Edge では使えない
Next.js の Route Handlers はデフォルトで Node.js ランタイムで動くが、export const runtime = 'edge' を設定している場合は net モジュール等が使えず、Socket.io の初期化が完全に失敗する。
よくある症状と対応するエラー
| 症状 | エラーメッセージ例 | 原因 |
|---|---|---|
| 接続がタイムアウト | xhr poll error / transport error |
Route Handler が WebSocket にアップグレードできない |
| 初回は繋がるが切断後に再接続できない | Error: server error |
io インスタンスがリクエストごとに上書きされる |
| ビルドエラー | Module not found: Can't resolve 'net' |
Edge ランタイムで Socket.io を import している |
| 接続数が増えるにつれ遅くなる | — | io が毎リクエスト new されてリスナーが増殖している |
解決策1:カスタムサーバーで Socket.io を初期化する(推奨)
最も確実な方法は、Next.js をカスタム Node.js サーバーから起動し、そこで Socket.io を管理することだ。
server.ts を作成する
// server.ts(プロジェクトルートに配置)
import { createServer } from 'http'
import { parse } from 'url'
import next from 'next'
import { Server } from 'socket.io'
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare().then(() => {
const httpServer = createServer((req, res) => {
const parsedUrl = parse(req.url!, true)
handle(req, res, parsedUrl)
})
const io = new Server(httpServer, {
cors: {
origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
methods: ['GET', 'POST'],
},
})
// Socket.io のイベント処理
io.on('connection', (socket) => {
console.log('client connected:', socket.id)
socket.on('message', (data: string) => {
io.emit('message', { text: data, id: socket.id })
})
socket.on('join-room', (room: string) => {
socket.join(room)
socket.to(room).emit('user-joined', socket.id)
})
socket.on('disconnect', () => {
console.log('client disconnected:', socket.id)
})
})
const port = parseInt(process.env.PORT || '3000', 10)
httpServer.listen(port, () => {
console.log(`> Ready on http://localhost:${port}`)
})
})
package.json のスクリプトを修正する
{
"scripts": {
"dev": "ts-node --project tsconfig.server.json server.ts",
"build": "next build",
"start": "NODE_ENV=production ts-node --project tsconfig.server.json server.ts"
}
}
tsconfig.server.json を追加する
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist"
},
"include": ["server.ts"]
}
フロントエンド(クライアント側)の実装
// hooks/useSocket.ts
import { useEffect, useRef } from 'react'
import { io, Socket } from 'socket.io-client'
export function useSocket() {
const socketRef = useRef<Socket | null>(null)
useEffect(() => {
// Next.js の同一オリジンに接続する
const socket = io({
path: '/socket.io',
})
socketRef.current = socket
socket.on('connect', () => {
console.log('connected:', socket.id)
})
socket.on('connect_error', (err) => {
console.error('connection error:', err.message)
})
return () => {
socket.disconnect()
}
}, [])
return socketRef
}
解決策2:Socket.io サーバーを Next.js と分離する
チャットや通知のようなリアルタイム機能は、Next.js とは独立したサービスとして立てる構成がスケーラビリティの面で優れている。
[Client]
│
├── HTTP/HTTPS ──► [Next.js :3000] (UI / REST API)
│
└── WebSocket ───► [Socket.io :4000] (リアルタイム)
Socket.io 専用サーバー(socket-server.ts)
// socket-server/index.ts
import { createServer } from 'http'
import { Server } from 'socket.io'
const httpServer = createServer()
const io = new Server(httpServer, {
cors: {
origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
credentials: true,
},
})
io.on('connection', (socket) => {
console.log('connected:', socket.id)
socket.on('message', (data) => {
io.emit('message', data)
})
socket.on('disconnect', () => {
console.log('disconnected:', socket.id)
})
})
const PORT = parseInt(process.env.SOCKET_PORT || '4000', 10)
httpServer.listen(PORT, () => {
console.log(`Socket.io server running on port ${PORT}`)
})
クライアントから別ポートに接続する
// hooks/useSocket.ts(分離構成の場合)
import { useEffect, useRef } from 'react'
import { io, Socket } from 'socket.io-client'
const SOCKET_URL = process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:4000'
export function useSocket() {
const socketRef = useRef<Socket | null>(null)
useEffect(() => {
const socket = io(SOCKET_URL, {
withCredentials: true,
})
socketRef.current = socket
return () => {
socket.disconnect()
}
}, [])
return socketRef
}
Vercel + Railway で運用する場合
Vercel は WebSocket を サポートしていない(2026年現在)。そのため Next.js を Vercel にデプロイし、Socket.io サーバーを Railway・Render・Fly.io などに分離デプロイするのが現実的な選択肢だ。
# Railway で Socket.io サーバーをデプロイする例
railway up --service socket-server
解決策3:next-ws を使う(実験的)
next-ws は App Router の Route Handler から WebSocket を扱えるようにするサードパーティパッケージだ。
npm install next-ws ws
npm install --save-dev @types/ws
// app/api/ws/route.ts
import { type NextRequest } from 'next/server'
export function SOCKET(
client: import('ws').WebSocket,
_request: NextRequest,
_server: import('ws').WebSocketServer,
) {
client.on('message', (message) => {
client.send(`Echo: ${message}`)
})
}
ただし next-ws は Socket.io ではなく生の WebSocket を扱う点に注意。Socket.io の高レベルな機能(ルーム、再接続、名前空間)は自前実装が必要になる。内部的に Next.js のビルドパイプラインにパッチを当てる実装のため、Next.js のバージョンアップで動かなくなるリスクがある。本番運用には解決策1か2を推奨する。
どの解決策を選ぶべきか
| 状況 | 推奨する解決策 |
|---|---|
| セルフホスト(VPS・ECS・GCP Run等)でデプロイする | 解決策1(カスタムサーバー) |
| Vercel にデプロイしたい | 解決策2(分離サーバー) |
| プロトタイプ・ローカル開発のみ | 解決策3(next-ws) |
| リアルタイム機能がコアビジネスで将来スケールさせたい | 解決策2(分離サーバー) |
よくあるハマりポイントと追加チェック
Socket.io クライアントのバージョン不一致
Socket.io はサーバーとクライアントのメジャーバージョンを揃える必要がある。
# サーバー側
npm install socket.io@4
# クライアント側(同じメジャーバージョン)
npm install socket.io-client@4
バージョンが違うと接続できても通信が壊れたり、無言で切断されたりする。
CORS エラーで接続できない
Socket.io のポーリング(HTTP)フェーズで CORS が発生する。
const io = new Server(httpServer, {
cors: {
// ❌ ワイルドカードは credentials と併用不可
// origin: '*',
// ✅ 明示的にオリジンを指定する
origin: ['http://localhost:3000', 'https://your-app.vercel.app'],
methods: ['GET', 'POST'],
credentials: true,
},
})
transport=websocket 固定でポーリングをスキップする
デフォルトでは Socket.io は HTTP ロングポーリングから始めて WebSocket にアップグレードする。環境によってはポーリングが失敗して接続できないケースがある。
const socket = io({
// 最初から WebSocket 接続を試みる(ポーリングをスキップ)
transports: ['websocket'],
})
まとめ
- App Router の Route Handlers は WebSocket 非対応。これが Socket.io が動かない根本原因。
- セルフホスト環境なら **カスタムサーバー(解決策1)**が最もシンプルで確実。
- Vercel にデプロイするなら Socket.io サーバーを **Railway 等に分離(解決策2)**するのが現実解。
- next-ws は実験的であり、本番運用には向かない。
- サーバー・クライアント間のバージョン一致と CORS 設定を必ず確認すること。
Pages Router 時代のコードをそのまま App Router に持ってきたときに最もよく踏む落とし穴なので、該当する方はまずカスタムサーバーへの移行を検討してほしい。