WebSockets avec Socket.io et Next.js : construire une application de chat en temps réel

La communication en temps réel, enfin démystifiée. Socket.io est la bibliothèque la plus populaire pour les WebSockets en JavaScript. Combinée avec Next.js, elle permet de construire des applications interactives — chat, notifications live, tableaux collaboratifs — avec une expérience développeur exceptionnelle.
Ce que vous apprendrez
À la fin de ce tutoriel, vous saurez :
- Comprendre le protocole WebSocket et ses différences avec HTTP
- Configurer Socket.io dans un projet Next.js avec TypeScript
- Construire un serveur WebSocket personnalisé intégré à Next.js
- Implémenter un chat en temps réel avec salons de discussion
- Ajouter des indicateurs de frappe et la présence utilisateur
- Gérer les événements personnalisés et la diffusion de messages
- Déployer votre application en production avec gestion de la reconnexion
Prérequis
Avant de commencer, assurez-vous de disposer de :
- Node.js 20+ installé (
node --version) - Expérience avec React et Next.js (App Router)
- Connaissances de base en TypeScript (types, interfaces, async/await)
- Un éditeur de code — VS Code ou Cursor recommandé
- Familiarité avec les concepts client-serveur
Comprendre les WebSockets
HTTP vs WebSocket
Le protocole HTTP fonctionne en mode requête-réponse : le client envoie une requête, le serveur répond, et la connexion se ferme. Pour obtenir des mises à jour, le client doit envoyer de nouvelles requêtes (polling).
Les WebSockets établissent une connexion bidirectionnelle persistante entre le client et le serveur. Une fois la connexion ouverte, les deux parties peuvent envoyer des messages à tout moment sans surcharge de nouvelles connexions.
HTTP classique :
Client → Requête → Serveur → Réponse → Connexion fermée
Client → Requête → Serveur → Réponse → Connexion fermée
WebSocket :
Client ←→ Connexion persistante ←→ Serveur
(messages dans les deux sens)
Pourquoi Socket.io ?
Socket.io ajoute une couche au-dessus des WebSockets natifs :
- Reconnexion automatique — reprend la connexion après une perte réseau
- Salons (rooms) — permet de grouper les connexions par canal
- Accusés de réception — confirme la réception des messages
- Fallback HTTP — fonctionne même si les WebSockets sont bloqués
- Support binaire — envoie des fichiers et des images
- Espaces de noms (namespaces) — sépare les logiques métier
Étape 1 : Initialiser le projet
Créez un nouveau projet Next.js avec TypeScript :
npx create-next-app@latest realtime-chat --typescript --tailwind --app --src-dir
cd realtime-chatInstallez Socket.io côté serveur et client :
npm install socket.io socket.io-clientVotre structure de projet ressemblera à :
realtime-chat/
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── api/
│ ├── components/
│ ├── lib/
│ │ └── socket.ts
│ └── types/
│ └── chat.ts
├── server.ts # Serveur personnalisé
├── package.json
└── tsconfig.json
Étape 2 : Définir les types TypeScript
Créez les types partagés entre le client et le serveur. C'est l'un des avantages de TypeScript — les événements Socket.io sont typés des deux côtés.
// src/types/chat.ts
export interface User {
id: string;
username: string;
color: string;
joinedAt: Date;
}
export interface Message {
id: string;
userId: string;
username: string;
color: string;
content: string;
room: string;
timestamp: Date;
}
export interface Room {
name: string;
users: User[];
messageCount: number;
}
// Événements du serveur vers le client
export interface ServerToClientEvents {
"message:new": (message: Message) => void;
"user:joined": (user: User, room: string) => void;
"user:left": (userId: string, room: string) => void;
"room:users": (users: User[]) => void;
"room:list": (rooms: Room[]) => void;
"typing:start": (userId: string, username: string) => void;
"typing:stop": (userId: string) => void;
}
// Événements du client vers le serveur
export interface ClientToServerEvents {
"message:send": (
content: string,
room: string,
callback: (success: boolean) => void
) => void;
"room:join": (room: string, username: string) => void;
"room:leave": (room: string) => void;
"typing:start": (room: string) => void;
"typing:stop": (room: string) => void;
}Étape 3 : Créer le serveur WebSocket
Next.js ne supporte pas nativement les WebSockets dans son serveur de développement. Nous allons créer un serveur personnalisé qui encapsule Next.js et ajoute Socket.io.
// server.ts
import { createServer } from "http";
import { parse } from "url";
import next from "next";
import { Server } from "socket.io";
import type {
ServerToClientEvents,
ClientToServerEvents,
User,
Message,
Room,
} from "./src/types/chat";
const dev = process.env.NODE_ENV !== "production";
const hostname = "localhost";
const port = parseInt(process.env.PORT || "3000", 10);
const app = next({ dev, hostname, port });
const handler = app.getRequestHandler();
// État en mémoire (remplacer par Redis en production)
const rooms = new Map<string, Set<string>>();
const users = new Map<string, User>();
const messageHistory = new Map<string, Message[]>();
// Salons par défaut
const DEFAULT_ROOMS = ["général", "tech", "random"];
function generateId(): string {
return Math.random().toString(36).substring(2, 15);
}
function getRandomColor(): string {
const colors = [
"#EF4444", "#F97316", "#EAB308", "#22C55E",
"#06B6D4", "#3B82F6", "#8B5CF6", "#EC4899",
];
return colors[Math.floor(Math.random() * colors.length)];
}
function getRoomData(roomName: string): Room {
const roomUsers = rooms.get(roomName) || new Set();
const roomUserList = Array.from(roomUsers)
.map((id) => users.get(id))
.filter(Boolean) as User[];
const messages = messageHistory.get(roomName) || [];
return {
name: roomName,
users: roomUserList,
messageCount: messages.length,
};
}
app.prepare().then(() => {
const httpServer = createServer((req, res) => {
const parsedUrl = parse(req.url!, true);
handler(req, res, parsedUrl);
});
const io = new Server<ClientToServerEvents, ServerToClientEvents>(
httpServer,
{
cors: {
origin: dev ? "http://localhost:3000" : process.env.NEXT_PUBLIC_URL,
methods: ["GET", "POST"],
},
pingTimeout: 60000,
pingInterval: 25000,
}
);
// Initialiser les salons par défaut
DEFAULT_ROOMS.forEach((room) => {
rooms.set(room, new Set());
messageHistory.set(room, []);
});
io.on("connection", (socket) => {
console.log(`Connexion: ${socket.id}`);
let currentUser: User | null = null;
let currentRoom: string | null = null;
// Rejoindre un salon
socket.on("room:join", (room: string, username: string) => {
// Créer l'utilisateur
currentUser = {
id: socket.id,
username,
color: getRandomColor(),
joinedAt: new Date(),
};
users.set(socket.id, currentUser);
// Quitter l'ancien salon si nécessaire
if (currentRoom) {
socket.leave(currentRoom);
rooms.get(currentRoom)?.delete(socket.id);
io.to(currentRoom).emit("user:left", socket.id, currentRoom);
io.to(currentRoom).emit(
"room:users",
Array.from(rooms.get(currentRoom) || [])
.map((id) => users.get(id))
.filter(Boolean) as User[]
);
}
// Rejoindre le nouveau salon
currentRoom = room;
socket.join(room);
if (!rooms.has(room)) {
rooms.set(room, new Set());
messageHistory.set(room, []);
}
rooms.get(room)!.add(socket.id);
// Notifier les autres utilisateurs
socket.to(room).emit("user:joined", currentUser, room);
// Envoyer la liste des utilisateurs du salon
const roomUsers = Array.from(rooms.get(room)!)
.map((id) => users.get(id))
.filter(Boolean) as User[];
io.to(room).emit("room:users", roomUsers);
// Envoyer la liste des salons mise à jour
const roomList = Array.from(rooms.keys()).map(getRoomData);
io.emit("room:list", roomList);
});
// Envoyer un message
socket.on("message:send", (content, room, callback) => {
if (!currentUser || !room) {
callback(false);
return;
}
const message: Message = {
id: generateId(),
userId: currentUser.id,
username: currentUser.username,
color: currentUser.color,
content: content.trim(),
room,
timestamp: new Date(),
};
// Sauvegarder dans l'historique (garder les 100 derniers)
const history = messageHistory.get(room) || [];
history.push(message);
if (history.length > 100) {
history.shift();
}
messageHistory.set(room, history);
// Diffuser à tous dans le salon
io.to(room).emit("message:new", message);
callback(true);
});
// Indicateurs de frappe
socket.on("typing:start", (room) => {
if (currentUser) {
socket
.to(room)
.emit("typing:start", currentUser.id, currentUser.username);
}
});
socket.on("typing:stop", (room) => {
if (currentUser) {
socket.to(room).emit("typing:stop", currentUser.id);
}
});
// Quitter un salon
socket.on("room:leave", (room) => {
socket.leave(room);
rooms.get(room)?.delete(socket.id);
if (currentUser) {
io.to(room).emit("user:left", currentUser.id, room);
}
currentRoom = null;
const roomUsers = Array.from(rooms.get(room) || [])
.map((id) => users.get(id))
.filter(Boolean) as User[];
io.to(room).emit("room:users", roomUsers);
});
// Déconnexion
socket.on("disconnect", () => {
console.log(`Déconnexion: ${socket.id}`);
if (currentRoom && currentUser) {
rooms.get(currentRoom)?.delete(socket.id);
io.to(currentRoom).emit("user:left", socket.id, currentRoom);
const roomUsers = Array.from(rooms.get(currentRoom) || [])
.map((id) => users.get(id))
.filter(Boolean) as User[];
io.to(currentRoom).emit("room:users", roomUsers);
}
users.delete(socket.id);
// Mettre à jour la liste des salons
const roomList = Array.from(rooms.keys()).map(getRoomData);
io.emit("room:list", roomList);
});
});
httpServer.listen(port, () => {
console.log(`> Serveur prêt sur http://${hostname}:${port}`);
});
});Mettez à jour les scripts dans package.json :
{
"scripts": {
"dev": "tsx server.ts",
"build": "next build",
"start": "NODE_ENV=production tsx server.ts"
}
}Installez tsx pour exécuter le serveur TypeScript :
npm install -D tsxÉtape 4 : Configurer le client Socket.io
Créez un module utilitaire pour la connexion client :
// src/lib/socket.ts
import { io, Socket } from "socket.io-client";
import type {
ServerToClientEvents,
ClientToServerEvents,
} from "@/types/chat";
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
let socket: TypedSocket | null = null;
export function getSocket(): TypedSocket {
if (!socket) {
socket = io({
autoConnect: false,
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
}
return socket;
}
export function connectSocket(): TypedSocket {
const s = getSocket();
if (!s.connected) {
s.connect();
}
return s;
}
export function disconnectSocket(): void {
if (socket?.connected) {
socket.disconnect();
}
}Étape 5 : Créer le hook useSocket
Un hook React personnalisé encapsule toute la logique Socket.io et la rend réutilisable dans vos composants :
// src/hooks/useSocket.ts
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { connectSocket, disconnectSocket, getSocket } from "@/lib/socket";
import type { Message, User, Room } from "@/types/chat";
interface UseSocketReturn {
isConnected: boolean;
messages: Message[];
users: User[];
rooms: Room[];
typingUsers: Map<string, string>;
joinRoom: (room: string, username: string) => void;
sendMessage: (content: string, room: string) => Promise<boolean>;
startTyping: (room: string) => void;
stopTyping: (room: string) => void;
}
export function useSocket(): UseSocketReturn {
const [isConnected, setIsConnected] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [rooms, setRooms] = useState<Room[]>([]);
const [typingUsers, setTypingUsers] = useState<Map<string, string>>(
new Map()
);
const typingTimeouts = useRef<Map<string, NodeJS.Timeout>>(new Map());
useEffect(() => {
const socket = connectSocket();
function onConnect() {
setIsConnected(true);
}
function onDisconnect() {
setIsConnected(false);
}
function onNewMessage(message: Message) {
setMessages((prev) => [...prev, message]);
}
function onUserJoined(user: User, room: string) {
// Message système
const systemMessage: Message = {
id: `system-${Date.now()}`,
userId: "system",
username: "Système",
color: "#6B7280",
content: `${user.username} a rejoint le salon`,
room,
timestamp: new Date(),
};
setMessages((prev) => [...prev, systemMessage]);
}
function onUserLeft(userId: string, room: string) {
const systemMessage: Message = {
id: `system-${Date.now()}`,
userId: "system",
username: "Système",
color: "#6B7280",
content: `Un utilisateur a quitté le salon`,
room,
timestamp: new Date(),
};
setMessages((prev) => [...prev, systemMessage]);
}
function onRoomUsers(updatedUsers: User[]) {
setUsers(updatedUsers);
}
function onRoomList(updatedRooms: Room[]) {
setRooms(updatedRooms);
}
function onTypingStart(userId: string, username: string) {
setTypingUsers((prev) => {
const next = new Map(prev);
next.set(userId, username);
return next;
});
// Supprimer automatiquement après 3 secondes
const existing = typingTimeouts.current.get(userId);
if (existing) clearTimeout(existing);
typingTimeouts.current.set(
userId,
setTimeout(() => {
setTypingUsers((prev) => {
const next = new Map(prev);
next.delete(userId);
return next;
});
}, 3000)
);
}
function onTypingStop(userId: string) {
setTypingUsers((prev) => {
const next = new Map(prev);
next.delete(userId);
return next;
});
}
socket.on("connect", onConnect);
socket.on("disconnect", onDisconnect);
socket.on("message:new", onNewMessage);
socket.on("user:joined", onUserJoined);
socket.on("user:left", onUserLeft);
socket.on("room:users", onRoomUsers);
socket.on("room:list", onRoomList);
socket.on("typing:start", onTypingStart);
socket.on("typing:stop", onTypingStop);
return () => {
socket.off("connect", onConnect);
socket.off("disconnect", onDisconnect);
socket.off("message:new", onNewMessage);
socket.off("user:joined", onUserJoined);
socket.off("user:left", onUserLeft);
socket.off("room:users", onRoomUsers);
socket.off("room:list", onRoomList);
socket.off("typing:start", onTypingStart);
socket.off("typing:stop", onTypingStop);
disconnectSocket();
};
}, []);
const joinRoom = useCallback((room: string, username: string) => {
const socket = getSocket();
setMessages([]); // Réinitialiser les messages
socket.emit("room:join", room, username);
}, []);
const sendMessage = useCallback(
(content: string, room: string): Promise<boolean> => {
return new Promise((resolve) => {
const socket = getSocket();
socket.emit("message:send", content, room, (success) => {
resolve(success);
});
});
},
[]
);
const startTyping = useCallback((room: string) => {
const socket = getSocket();
socket.emit("typing:start", room);
}, []);
const stopTyping = useCallback((room: string) => {
const socket = getSocket();
socket.emit("typing:stop", room);
}, []);
return {
isConnected,
messages,
users,
rooms,
typingUsers,
joinRoom,
sendMessage,
startTyping,
stopTyping,
};
}Étape 6 : Construire le composant Chat
6.1 — Le formulaire de connexion
// src/components/JoinForm.tsx
"use client";
import { useState } from "react";
interface JoinFormProps {
onJoin: (username: string) => void;
}
export function JoinForm({ onJoin }: JoinFormProps) {
const [username, setUsername] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = username.trim();
if (trimmed.length >= 2 && trimmed.length <= 20) {
onJoin(trimmed);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-950">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm rounded-2xl bg-gray-900 p-8 shadow-xl"
>
<h1 className="mb-6 text-center text-2xl font-bold text-white">
Rejoindre le Chat
</h1>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Votre pseudo"
maxLength={20}
className="mb-4 w-full rounded-lg bg-gray-800 px-4 py-3 text-white
placeholder-gray-500 outline-none ring-1 ring-gray-700
focus:ring-blue-500"
autoFocus
/>
<button
type="submit"
disabled={username.trim().length < 2}
className="w-full rounded-lg bg-blue-600 py-3 font-semibold
text-white transition hover:bg-blue-500
disabled:opacity-40 disabled:cursor-not-allowed"
>
Entrer
</button>
</form>
</div>
);
}6.2 — La liste des messages
// src/components/MessageList.tsx
"use client";
import { useEffect, useRef } from "react";
import type { Message } from "@/types/chat";
interface MessageListProps {
messages: Message[];
currentUserId: string;
}
export function MessageList({ messages, currentUserId }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
return (
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((msg) => {
const isSystem = msg.userId === "system";
const isOwn = msg.userId === currentUserId;
if (isSystem) {
return (
<div key={msg.id} className="text-center text-sm text-gray-500">
{msg.content}
</div>
);
}
return (
<div
key={msg.id}
className={`flex ${isOwn ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[70%] rounded-2xl px-4 py-2 ${
isOwn
? "bg-blue-600 text-white"
: "bg-gray-800 text-gray-100"
}`}
>
{!isOwn && (
<div
className="text-xs font-semibold mb-1"
style={{ color: msg.color }}
>
{msg.username}
</div>
)}
<p className="text-sm leading-relaxed">{msg.content}</p>
<div
className={`text-xs mt-1 ${
isOwn ? "text-blue-200" : "text-gray-500"
}`}
>
{new Date(msg.timestamp).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
);
}6.3 — Le champ de saisie avec indicateur de frappe
// src/components/MessageInput.tsx
"use client";
import { useState, useRef, useCallback } from "react";
interface MessageInputProps {
onSend: (content: string) => Promise<boolean>;
onTypingStart: () => void;
onTypingStop: () => void;
typingUsers: Map<string, string>;
disabled?: boolean;
}
export function MessageInput({
onSend,
onTypingStart,
onTypingStop,
typingUsers,
disabled,
}: MessageInputProps) {
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
const typingTimer = useRef<NodeJS.Timeout | null>(null);
const isTyping = useRef(false);
const handleTyping = useCallback(() => {
if (!isTyping.current) {
isTyping.current = true;
onTypingStart();
}
if (typingTimer.current) {
clearTimeout(typingTimer.current);
}
typingTimer.current = setTimeout(() => {
isTyping.current = false;
onTypingStop();
}, 2000);
}, [onTypingStart, onTypingStop]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = content.trim();
if (!trimmed || sending) return;
setSending(true);
isTyping.current = false;
onTypingStop();
const success = await onSend(trimmed);
if (success) {
setContent("");
}
setSending(false);
}
const typingNames = Array.from(typingUsers.values());
let typingText = "";
if (typingNames.length === 1) {
typingText = `${typingNames[0]} est en train d'écrire...`;
} else if (typingNames.length === 2) {
typingText = `${typingNames[0]} et ${typingNames[1]} écrivent...`;
} else if (typingNames.length > 2) {
typingText = `${typingNames.length} personnes écrivent...`;
}
return (
<div className="border-t border-gray-800 bg-gray-900 p-4">
{typingText && (
<div className="mb-2 text-xs text-gray-400 animate-pulse">
{typingText}
</div>
)}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={content}
onChange={(e) => {
setContent(e.target.value);
handleTyping();
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
handleSubmit(e);
}
}}
placeholder="Tapez votre message..."
disabled={disabled}
className="flex-1 rounded-lg bg-gray-800 px-4 py-3 text-white
placeholder-gray-500 outline-none ring-1 ring-gray-700
focus:ring-blue-500 disabled:opacity-50"
autoFocus
/>
<button
type="submit"
disabled={!content.trim() || sending || disabled}
className="rounded-lg bg-blue-600 px-6 py-3 font-semibold
text-white transition hover:bg-blue-500
disabled:opacity-40 disabled:cursor-not-allowed"
>
Envoyer
</button>
</form>
</div>
);
}6.4 — La barre latérale des salons et utilisateurs
// src/components/Sidebar.tsx
"use client";
import type { Room, User } from "@/types/chat";
interface SidebarProps {
rooms: Room[];
currentRoom: string;
users: User[];
isConnected: boolean;
onRoomSelect: (room: string) => void;
}
export function Sidebar({
rooms,
currentRoom,
users,
isConnected,
onRoomSelect,
}: SidebarProps) {
return (
<aside className="flex w-64 flex-col border-r border-gray-800 bg-gray-900">
{/* Statut de connexion */}
<div className="flex items-center gap-2 border-b border-gray-800 p-4">
<div
className={`h-2.5 w-2.5 rounded-full ${
isConnected ? "bg-green-500" : "bg-red-500"
}`}
/>
<span className="text-sm text-gray-400">
{isConnected ? "Connecté" : "Déconnecté"}
</span>
</div>
{/* Liste des salons */}
<div className="flex-1 overflow-y-auto p-3">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
Salons
</h2>
<ul className="space-y-1">
{rooms.map((room) => (
<li key={room.name}>
<button
onClick={() => onRoomSelect(room.name)}
className={`w-full rounded-lg px-3 py-2 text-left text-sm
transition ${
currentRoom === room.name
? "bg-blue-600/20 text-blue-400"
: "text-gray-300 hover:bg-gray-800"
}`}
>
<span className="mr-1">#</span>
{room.name}
<span className="ml-auto text-xs text-gray-600">
{" "}
({room.users.length})
</span>
</button>
</li>
))}
</ul>
</div>
{/* Utilisateurs en ligne */}
<div className="border-t border-gray-800 p-3">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
En ligne ({users.length})
</h2>
<ul className="space-y-1">
{users.map((user) => (
<li key={user.id} className="flex items-center gap-2 px-2 py-1">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: user.color }}
/>
<span className="text-sm text-gray-300">{user.username}</span>
</li>
))}
</ul>
</div>
</aside>
);
}Étape 7 : Assembler la page principale
// src/app/page.tsx
"use client";
import { useState, useCallback } from "react";
import { useSocket } from "@/hooks/useSocket";
import { JoinForm } from "@/components/JoinForm";
import { Sidebar } from "@/components/Sidebar";
import { MessageList } from "@/components/MessageList";
import { MessageInput } from "@/components/MessageInput";
import { getSocket } from "@/lib/socket";
const DEFAULT_ROOM = "général";
export default function ChatPage() {
const [username, setUsername] = useState<string | null>(null);
const [currentRoom, setCurrentRoom] = useState(DEFAULT_ROOM);
const {
isConnected,
messages,
users,
rooms,
typingUsers,
joinRoom,
sendMessage,
startTyping,
stopTyping,
} = useSocket();
const handleJoin = useCallback(
(name: string) => {
setUsername(name);
joinRoom(DEFAULT_ROOM, name);
},
[joinRoom]
);
const handleRoomSelect = useCallback(
(room: string) => {
if (room !== currentRoom && username) {
setCurrentRoom(room);
joinRoom(room, username);
}
},
[currentRoom, username, joinRoom]
);
const handleSend = useCallback(
(content: string) => sendMessage(content, currentRoom),
[sendMessage, currentRoom]
);
const handleTypingStart = useCallback(
() => startTyping(currentRoom),
[startTyping, currentRoom]
);
const handleTypingStop = useCallback(
() => stopTyping(currentRoom),
[stopTyping, currentRoom]
);
if (!username) {
return <JoinForm onJoin={handleJoin} />;
}
const socketId = getSocket().id || "";
return (
<div className="flex h-screen bg-gray-950 text-white">
<Sidebar
rooms={rooms}
currentRoom={currentRoom}
users={users}
isConnected={isConnected}
onRoomSelect={handleRoomSelect}
/>
<main className="flex flex-1 flex-col">
{/* En-tête du salon */}
<header className="flex items-center border-b border-gray-800 px-6 py-4">
<h1 className="text-lg font-semibold">
<span className="text-gray-500 mr-1">#</span>
{currentRoom}
</h1>
<span className="ml-3 text-sm text-gray-500">
{users.length} membre{users.length > 1 ? "s" : ""}
</span>
</header>
{/* Messages */}
<MessageList messages={messages} currentUserId={socketId} />
{/* Saisie */}
<MessageInput
onSend={handleSend}
onTypingStart={handleTypingStart}
onTypingStop={handleTypingStop}
typingUsers={typingUsers}
disabled={!isConnected}
/>
</main>
</div>
);
}Étape 8 : Tester en local
Lancez le serveur de développement :
npm run devOuvrez deux onglets sur http://localhost:3000 :
- Dans le premier onglet, entrez un pseudo (ex: "Alice") et rejoignez le chat
- Dans le second onglet, entrez un autre pseudo (ex: "Bob")
- Envoyez des messages — ils apparaissent instantanément dans les deux onglets
- Tapez dans un champ — l'indicateur de frappe apparaît dans l'autre onglet
- Changez de salon — les utilisateurs en ligne se mettent à jour
Étape 9 : Améliorations pour la production
9.1 — Reconnexion robuste
Socket.io gère la reconnexion automatiquement, mais vous pouvez ajuster le comportement dans le hook :
// Ajout dans useSocket.ts - dans le useEffect
socket.on("connect_error", (error) => {
console.error("Erreur de connexion:", error.message);
});
socket.io.on("reconnect", (attempt) => {
console.log(`Reconnecté après ${attempt} tentative(s)`);
// Re-rejoindre le salon après reconnexion
if (currentRoom && username) {
socket.emit("room:join", currentRoom, username);
}
});
socket.io.on("reconnect_failed", () => {
console.error("Reconnexion impossible");
});9.2 — Adapter avec Redis pour les déploiements multi-serveurs
En production avec plusieurs instances de serveur, les WebSockets ne sont connectés qu'à une seule instance. Le package @socket.io/redis-adapter synchronise les événements entre toutes les instances :
npm install @socket.io/redis-adapter ioredis// server.ts - ajout de l'adaptateur Redis
import { createClient } from "ioredis";
import { createAdapter } from "@socket.io/redis-adapter";
const pubClient = new createClient({ host: "localhost", port: 6379 });
const subClient = pubClient.duplicate();
io.adapter(createAdapter(pubClient, subClient));Avec cet adaptateur, un message envoyé à l'instance A sera automatiquement relayé aux clients connectés à l'instance B.
9.3 — Limitation du débit
Protégez votre serveur contre les abus en limitant le nombre de messages par seconde :
// Ajout dans la gestion du socket côté serveur
const rateLimits = new Map<string, number[]>();
function isRateLimited(socketId: string): boolean {
const now = Date.now();
const timestamps = rateLimits.get(socketId) || [];
// Garder seulement les messages des 10 dernières secondes
const recent = timestamps.filter((t) => now - t < 10000);
rateLimits.set(socketId, recent);
if (recent.length >= 10) {
return true; // Maximum 10 messages par 10 secondes
}
recent.push(now);
return false;
}
// Dans le handler "message:send"
socket.on("message:send", (content, room, callback) => {
if (isRateLimited(socket.id)) {
callback(false);
return;
}
// ... reste du code
});Étape 10 : Déploiement avec Docker
Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/server.ts ./
COPY --from=builder /app/src/types ./src/types
COPY --from=builder /app/tsconfig.json ./
EXPOSE 3000
CMD ["npx", "tsx", "server.ts"]docker-compose.yml
version: "3.8"
services:
chat:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_URL=https://chat.example.com
restart: unless-stopped
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
volumes:
redis_data:Lancez avec :
docker compose up -dDépannage
Les messages ne sont pas reçus
- Vérifiez que le serveur personnalisé est lancé (pas
next devdirectement) - Ouvrez les DevTools et vérifiez l'onglet Réseau — une connexion WebSocket doit être visible
- Vérifiez les logs du serveur pour les erreurs de connexion
Erreur CORS en production
Assurez-vous que NEXT_PUBLIC_URL correspond exactement au domaine de votre application. Le protocole (https://) et le port sont importants.
Les indicateurs de frappe restent bloqués
Le timeout de 3 secondes dans le hook côté client devrait nettoyer les indicateurs. Si le problème persiste, vérifiez que les événements typing:stop sont bien émis à la déconnexion.
Mémoire qui augmente en production
L'état en mémoire (Map) de notre serveur ne convient pas pour la production à grande échelle. Utilisez Redis pour stocker les sessions utilisateur et l'historique des messages.
Prochaines étapes
- Authentification — intégrez Better Auth pour les comptes utilisateur
- Persistance — stockez les messages dans PostgreSQL avec Drizzle ORM
- Notifications push — ajoutez des notifications navigateur pour les messages reçus hors focus
- Partage de fichiers — utilisez les capacités binaires de Socket.io pour envoyer des images
- Messages privés — implémentez un système de DM avec des salons privés
- Chiffrement de bout en bout — ajoutez une couche de sécurité avec Web Crypto API
Conclusion
Vous avez construit une application de chat en temps réel complète avec :
- Un serveur WebSocket personnalisé intégré à Next.js avec Socket.io
- Des événements typés grâce à TypeScript des deux côtés
- Des salons de discussion avec gestion de la présence
- Des indicateurs de frappe en temps réel
- Des stratégies de production : reconnexion, Redis, limitation de débit et Docker
Les WebSockets ouvrent un monde de possibilités au-delà du chat : tableaux collaboratifs, jeux multijoueurs, notifications en direct, éditeurs de code partagés. Socket.io vous fournit les bases solides pour construire tout cela avec une API élégante et une gestion robuste des connexions.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Construire une application complète avec Firebase et Next.js 15 : Auth, Firestore et temps réel
Apprenez à créer une application full-stack avec Next.js 15 et Firebase. Ce guide couvre l'authentification, Firestore, les mises à jour en temps réel, les Server Actions et le déploiement sur Vercel.

Authentifier votre application Next.js 15 avec Auth.js v5 : Email, OAuth et contrôle des rôles
Apprenez à ajouter une authentification prête pour la production à votre application Next.js 15 avec Auth.js v5. Ce guide complet couvre Google OAuth, les identifiants email/mot de passe, les routes protégées, le middleware et le contrôle d'accès basé sur les rôles.

Construire un Chatbot IA Local avec Ollama et Next.js : Guide Complet
Construisez un chatbot IA privé fonctionnant entièrement sur votre machine locale avec Ollama et Next.js. Ce tutoriel pratique couvre l'installation, le streaming des réponses, la sélection de modèles et le déploiement d'une interface de chat prête pour la production — le tout sans envoyer de données dans le cloud.