Pourquoi les WebSockets bloquent dans les routes API de Next.js et comment un serveur personnalisé y remédie
Découvrez pourquoi un serveur WebSocket à l’intérieur d’une route pages/api ne termine jamais la séquence de connexion, et comment gérer vous-même l’événement d’upgrade avec un serveur Next.js personnalisé.
Sources: The Next.js WebSocket Trap: Why Your pages/api Handler Never Upgrades
Une première approche courante pour utiliser WebSockets dans Next.js consiste à créer un serveur ws dans pages/api/socket.ts et à s’y connecter depuis le navigateur. Le résultat est déroutant : pas d’erreur, pas de message, simplement un socket resté dans l’état CONNECTING jusqu’à ce que le navigateur expire. Ce guide explique pourquoi cela se produit, montre comment prendre le contrôle du serveur HTTP afin que la mise en relation s’achève réellement, et aborde les limites de scalabilité et d’hébergement que l’on accepte en échange.
Pourquoi une route API ne peut jamais améliorer la connexion
Une route API de Next.js est un gestionnaire de requêtes HTTP ordinaire. Elle s’exécute une fois que la requête a déjà été acheminée en tant qu’HTTP classique. En revanche, une mise en relation WebSocket est une requête HTTP contenant l’en-tête Upgrade, et elle doit être interceptée via l’événement upgrade du serveur Node avant que tout routeur ne la traite. Au moment où la fonction de la route s’exécute, cette opportunité est déjà passée. La route répond par du JSON tandis que le client attend une réponse 101 Switching Protocols, et aucune des deux parties ne progresse.
La version défectueuse commence par les imports habituels, y compris les types API de Next :
// pages/api/socket.ts — DOES NOT WORK
import { WebSocketServer } from "ws";
import type { NextApiRequest, NextApiResponse } from "next";
La route crée ensuite un WebSocketServer et y associe un gestionnaire de connexion qui répète les messages, mais son gestionnaire exporté se contente de renvoyer un statut JSON.
const wss = new WebSocketServer({ noServer: true });wss.on("connection", (ws) => {
ws.on("message", (msg) => ws.send(`echo: ${msg}`));
});export default function handler(_req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({ status: "ws server" });
}
L’instance wss existe, et noServer: true est même la bonne configuration. Ce qui manque, c’est toute appel à handleUpgrade. Rien ne transmet le socket à ws, ce qui empêche new WebSocket(...) du navigateur de finaliser la connexion.
Prendre en charge le serveur HTTP
La solution consiste à cesser d’attendre qu’une route API s’en charge et à contrôler directement le serveur HTTP. Next.js prend en charge un serveur personnalisé : vous créez vous-même le serveur HTTP Node, vous transmettez les requêtes normales au gestionnaire de requêtes de Next, et vous écoutez les événements upgrade vous-même.
// server.ts
import { createServer } from "node:http";
import next from "next";
import { WebSocketServer, type WebSocket } from "ws";
import { parse } from "node:url";
Le script serveur ci-dessous effectue quatre tâches :
- Il lance Next en mode développement ou de production et obtient le gestionnaire de requêtes par défaut.
WebSocketServer avec noServer: true et suive les sockets ouverts dans un Set, en les supprimant lors des événements close et error.app.prepare() est terminé, il crée le serveur HTTP et, dans l’écouteur upgrade, vérifie le chemin : /api/ws est transmis à wss.handleUpgrade, tandis que tout autre chemin est supprimé. Enfin, il écoute sur PORT ou 3000.const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();const wss = new WebSocketServer({ noServer: true });
const clients = new Set<WebSocket>();wss.on("connection", (ws) => {
clients.add(ws);
ws.on("message", (data) => {
const text = data.toString();
for (const client of clients) {
if (client.readyState === client.OPEN) client.send(text);
}
});
ws.on("close", () => clients.delete(ws));
ws.on("error", () => clients.delete(ws));
});app.prepare().then(() => {
const server = createServer((req, res) => handle(req, res, parse(req.url!, true))); server.on("upgrade", (req, socket, head) => {
const { pathname } = new URL(req.url ?? "/", "http://localhost");
if (pathname === "/api/ws") {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
} else {
socket.destroy();
}
}); const port = Number(process.env.PORT ?? 3000);
server.listen(port, () => console.log(`ready on http://localhost:${port}`));
});
Les deux éléments clés du design
Tout d’abord, noServer: true indique à ws de ne pas ouvrir d’écouteur propre, car les demandes de mise à niveau seront fournies manuellement.
Deuxièmement, la vérification du chemin est plus importante qu’il n’y paraît. Sans elle, toute demande de mise à niveau arrivant sur le serveur serait traitée par votre fonction handleUpgrade, y compris celle liée au remplacement en temps réel de modules que Next utilise pendant le développement. Ce mécanisme de protection permet de maintenir votre point d’entrée /api/ws séparé. Cependant, sachez que la branche else détruit toutes les autres mises à niveau ; selon la version de Next.js que vous utilisez, cela peut également interrompre la connexion HMR en mode développement. Si le chargement en temps réel cesse de fonctionner, redirigez les mises à niveau qui ne correspondent pas vers Next au lieu de les supprimer (consultez la section sur le serveur personnalisé dans la documentation actuelle de Next.js pour connaître la méthode recommandée).
L’ordre des arguments de handleUpgrade est facile à confondre. Comme indiqué dans la documentation de ws 8.18.3, il s’agit de handleUpgrade(request, socket, head, callback). À l’intérieur du callback, c’est vous-même qui émettez connection sur le serveur, de sorte que le gestionnaire wss.on("connection") fonctionne exactement comme pour un serveur indépendant.
Le composant client
La partie côté navigateur est délibérément simple. En tant que composant client, il commence par la directive "use client" ainsi que les hooks React nécessaires.
"use client";
import { useEffect, useState } from "react";
LiveFeed ouvre un socket vers le même chemin protégé par le serveur, ajoute chaque message reçu à l’état, puis affiche la liste.
export function LiveFeed() {
const [messages, setMessages] = useState<string[]>([]); useEffect(() => {
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${window.location.host}/api/ws`); ws.onmessage = (event) => {
setMessages((prev) => [...prev, event.data as string]);
}; return () => ws.close();
}, []); return (
<ul>
{messages.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
);
}
Deux choix ici permettent d’éviter de vrais bugs :
- Le protocole est dérivé de
window.location.protocolau lieu d’être codé en dur avecws://. En production, la page est servie via HTTPS, et les navigateurs bloquent une connexionws://non chiffrée depuis une pagehttps://; donc le codage en dur ne fonctionnerait que après déploiement. - Cet effet retourne une fonction de nettoyage qui appelle
ws.close(). En développement, le mode strict de React exécute les effets deux fois, si bien qu’en l’absence de cette fonction de nettoyage, un socket supplémentaire serait laissé ouvert à chaque chargement de la page.
Démarrage du serveur
Démarrez l’application avec tsx server.ts, ou son équivalent compilé en production, plutôt qu’avec next dev. Abandonner la commande de développement sans configuration en échange du contrôle total sur le serveur HTTP représente le véritable coût des WebSockets natifs dans cette configuration.
Vérification du comportement
Vérification manuelle rapide effectuée sur Node 24 avec ws 8.18.3 confirme chaque aspect :
- Lorsque deux clients sont connectés, un message tel que
hello from aenvoyé par le premier arrive au second via la boucle de diffusion. - Un socket ouvert sur un chemin autre que
/api/wséchoue immédiatement sans rester bloqué, carsocket.destroy()le ferme aussitôt au lieu de le laisser en attente comme c’est le cas avec la version basée sur des routes API. - Une route HTTP ordinaire sur le même serveur renvoie toujours son corps, ce qui prouve que le traitement des requêtes de Next et l’upgrade WebSocket partagent un même écouteur.
Où cette approche perd en capacité de scaling
Un Set en mémoire contenant des clients au sein d’un seul processus est suffisant tant que vous n’exécutez qu’une seule instance. Avec deux pods derrière un équilibreur de charge, un message reçu sur le pod A n’atteint jamais un client connecté au pod B, car chaque processus dispose de son propre ensemble de clients. Dans ce cas, vous avez besoin d’une couche pub/sub, le plus souvent Redis, pour diffuser les messages entre les instances, ou d’un service en temps réel géré. Pour en savoir plus sur les salles de discussion, la persistance des données et la diffusion des messages, consultez la conception de backends de chat en temps réel. Ne construisez pas ce système dès le début : un processus Node peut gérer de nombreux sockets simultanés, et de nombreuses applications n’en ont jamais besoin au-delà de cette capacité.
Contraintes d’hébergement
Points clés
- La négociation WebSocket a lieu lors de l’événement
upgradedu serveur, que les routes API ne perçoivent jamais. - Un serveur personnalisé vous permet d’orienter les mises à niveau vers
wsen utilisantnoServer: trueethandleUpgrade, tandis que Next gère le HTTP normal. - Sécurisez les mises à niveau en fonction du chemin, et soyez attentif à ce qui arrive aux mises à niveau qui ne vous appartiennent pas.
- Déterminez le protocole
wsouwssen fonction du protocole de la page, et fermez toujours les sockets lors du nettoyage.