Next.js 15 + Socket.io: архитектурные паттерны для full-stack приложений

#nextjs#socket.io#fullstack#react#websockets

TL;DR

Интеграция Socket.io с Next.js 15 в одном процессе через кастомный server.js — рабочий паттерн для real-time приложений, жертвующий Vercel-деплоем ради единой сессии и упрощённой инфраструктуры. Разберём плюсы, подводные камни и альтернативы.

Введение: контекст проблемы

С выходом Next.js 15 многие разработчики столкнулись с ограничением: middleware не предоставляет хуков для WebSocket-подключений. Классическое решение с отдельным сервером для сокетов усложняет:

Пример из практики: Minipad реализует альтернативный подход — единый Node.js процесс, где Next.js и Socket.io работают вместе.

Основная часть: кастомный сервер в Next.js 15

Архитектурный паттерн

// 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 app = next({ dev })
const handler = app.getRequestHandler()

app.prepare().then(() => {
  const httpServer = createServer((req, res) => {
    const parsedUrl = parse(req.url, true)
    handler(req, res, parsedUrl)
  })

  const io = new Server(httpServer, {
    path: '/api/socket',
    cors: { origin: '*' } // На продакшене заменить на конкретные домены
  })

  io.on('connection', (socket) => {
    console.log('Client connected:', socket.id)
    
    socket.on('join-note', (noteId) => {
      socket.join(`note:${noteId}`)
    })

    socket.on('update-content', ({ noteId, content }) => {
      io.to(`note:${noteId}`).emit('content-updated', content)
    })
  })

  httpServer.listen(3000, () => {
    console.log('> Ready on http://localhost:3000')
  })
})

Ключевые особенности:

  1. Общий HTTP-сервер: Next.js и Socket.io используют один экземпляр http.Server
  2. Единая сессия: Куки аутентификации доступны и в HTTP, и в WebSocket-запросах
  3. Порт 3000: Обрабатывает оба типа соединений

Trade-off анализатор

Плюсы (+)Минусы (-)
Упрощённый деплойПотеря Vercel-деплоя
Общий контекст аутентификацииНет автоматического масштабирования
Локальная разработка “как на проде”Требует Node.js-сервер

Практическое применение: real-time редактор

Интеграция с React

// hooks/useSocket.ts
import { useEffect, useState } from 'react'
import { io, Socket } from 'socket.io-client'

export const useSocket = (noteId: string) => {
  const [socket, setSocket] = useState<Socket | null>(null)
  const [content, setContent] = useState('')

  useEffect(() => {
    const socketInstance = io('', { path: '/api/socket' })

    socketInstance.on('connect', () => {
      socketInstance.emit('join-note', noteId)
    })

    socketInstance.on('content-updated', (newContent: string) => {
      setContent(newContent)
    })

    setSocket(socketInstance)

    return () => {
      socketInstance.disconnect()
    }
  }, [noteId])

  const emitUpdate = (newContent: string) => {
    socket?.emit('update-content', { noteId, content: newContent })
  }

  return { content, emitUpdate }
}

Типичные проблемы и решения:

  1. Гидратация в React 19:

    • Проблема: Разрыв между SSR-контентом и клиентским состоянием
    • Решение: Использовать useSyncExternalStore для WebSocket-данных
  2. Авторизация:

    // В middleware Next.js
    io.use((socket, next) => {
      const cookie = socket.handshake.headers.cookie
      const session = parseCookie(cookie)
      if (!session.user) return next(new Error('Unauthorized'))
      socket.data.user = session.user
      next()
    })
    

Альтернативные подходы

1. Vercel-совместимый вариант (Edge Functions + отдельный сервер)

// app/api/socket/route.js
import { NextResponse } from 'next/server'

export const runtime = 'edge'

export async function GET() {
  const response = NextResponse.next()
  response.headers.set('upgrade', 'websocket')
  response.headers.set('connection', 'upgrade')
  return response
}

2. Server-Sent Events (SSE) как lightweight-альтернатива

// app/api/sse/route.ts
export async function GET() {
  const stream = new TransformStream()
  const writer = stream.writable.getWriter()

  const encoder = new TextEncoder()
  const send = (data: string) => {
    writer.write(encoder.encode(`data: ${data}\n\n`))
  }

  // Пример подписки на изменения
  setInterval(() => {
    send(JSON.stringify({ time: Date.now() }))
  }, 1000)

  return new Response(stream.readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive'
    }
  })
}

Заключение: когда что выбирать

Кастомный server.js подходит когда:

Vercel + отдельный сервер лучше для:

Для большинства production-проектов с real-time требованиями паттерн единого процесса доказал свою эффективность, несмотря на потерю Vercel-специфичных фич. Главное — явно документировать архитектурные решения для команды.


Попробуй сам: DigitalOcean — $200 кредитов для новых пользователей.


Источник: https://www.reddit.com/r/reactjs/comments/1sz6ipl/built_a_selfhostable_notepad_with_nextjs_15/