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'],
})

まとめ

Pages Router 時代のコードをそのまま App Router に持ってきたときに最もよく踏む落とし穴なので、該当する方はまずカスタムサーバーへの移行を検討してほしい。

技術ブログの記事一覧へ