Build a Real-Time Chat Room
Build a real-time chat app with Next.js, Socket.IO, and WebSockets.
Introduction
30 Second Summary
Think about how group chats work. When someone sends a message, everyone sees it instantly without refreshing their screen.
In this project, you will build a real-time chat room where multiple users can join, send messages, and see who is currently online. Messages appear instantly for all connected users using a persistent WebSocket connection between the browser and server.
What You'll Build
A real-time chat application using Next.js and Socket.IO that supports live messaging, typing indicators, and online user presence tracking.
By the end of this project, you'll have:
- A custom Node.js server that wraps Next.js with Socket.IO to handle WebSocket connections on the same port.
- A reusable useSocket hook that manages WebSocket connection lifecycle, reconnection state, and message handling in React.
- A chat interface with a live message feed, username entry, typing indicators, and an online users sidebar.
- Secret Mission: Add multiple chat rooms with the ability to switch between them.
Are there any prerequisites?
This project assumes you have experience with the Next.js App Router, TypeScript basics, and React hooks (useState and useEffect). No prior WebSocket or Socket.IO experience is needed.
Set Up the Project
To build a real-time chat room, you need a project that can serve a web interface and handle persistent WebSocket connections.
In this step, you'll scaffold a Next.js project with TypeScript and Tailwind CSS, then install Socket.IO for real-time communication.
In this step, get ready to:
- Scaffold a Next.js project with TypeScript and Tailwind CSS.
- Install Socket.IO for real-time WebSocket communication.
- Configure the project scripts for a custom server.
Scaffold a new Next.js project
Next.js gives you a React framework with routing, server rendering, and a dev server built in. The scaffolding command creates the full project structure in one shot.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Next.js project by running this command:
npx create-next-app@latest realtime-chat --typescript --tailwind --app --eslint --src-dir --no-import-alias
What does this command do?
The flags pre-answer all of the interactive setup questions so the command runs without prompting you.
- npx create-next-app@latest downloads and runs the latest Next.js scaffolding tool.
- realtime-chat is the project folder name.
- --typescript enables TypeScript for type safety.
- --tailwind adds Tailwind CSS for styling.
- --app uses the App Router (the modern Next.js routing system).
- --src-dir places your application code inside a src/ folder.
- Once the command finishes, navigate into the new project folder by running:
cd realtime-chat
You should see a success message in the terminal confirming the project was created.
Seeing a 'command not found' error?
Make sure Node.js is installed by running node --version in your terminal. The npx command comes bundled with Node.js.
If you see a permission error, try running the command with sudo on macOS/Linux or opening your terminal as Administrator on Windows.
help me fix this create-next-app error
Install Socket.IO and dev dependencies
Socket.IO is a library that enables real-time, bidirectional communication between your server and browser clients. You need both the server package and the client package.
- Install the Socket.IO server and client packages by running:
npm install socket.io@4.8.3 socket.io-client@4.8.3
- Install the dev dependencies by running:
npm install -D tsx concurrently @types/node
What are these packages?
socket.io runs on your server and manages WebSocket connections. socket.io-client runs in the browser and connects to the server.
tsx lets you run TypeScript files directly without a separate compile step. concurrently can run multiple processes in parallel. @types/node provides TypeScript type definitions for Node.js APIs.
You should see output confirming packages were added to your package.json.
Install failing with a network error?
Check your internet connection and try running the install command again. If you see permission errors, avoid using sudo with npm install. Instead, fix npm permissions by following the npm docs.
help me fix this npm install error
Update the package.json scripts
By default, Next.js uses its own dev server. You need to replace the default scripts so the project uses your custom server instead. The custom server will handle both Next.js page serving and WebSocket connections.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the realtime-chat folder on your Desktop, then click Open.
- In the Explorer sidebar, click package.json to open it.
- Find the "scripts" section and replace it with the following:
"scripts": {
"dev": "tsx --watch server.ts",
"build": "next build",
"start": "node server.ts"
}
What do these scripts do?
The dev script uses tsx to run your custom TypeScript server file directly. The --watch flag restarts the server automatically when you save changes.
The start script runs the same server in production using plain Node.js.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Not sure where to paste?
Look for the section that starts with "scripts": { in your package.json. Replace everything from that opening brace to its matching closing brace. Make sure you keep the comma after the closing brace if there is content below it.
help me update my package.json scripts section
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Your package.json scripts section should look exactly like this:
"scripts": {
"dev": "tsx --watch server.ts",
"build": "next build",
"start": "node server.ts"
}
Your dependencies section should include socket.io and socket.io-client at version 4.8.3. Your devDependencies should include tsx, concurrently, and @types/node.
Your project is scaffolded and all dependencies are installed. Next up, you'll create the custom WebSocket server that powers real-time communication.
Create the Custom WebSocket Server
Your chat app needs messages to flow instantly between users without any page refresh. Regular HTTP follows a request-response pattern where the client must keep asking the server for updates. WebSockets solve this by keeping a persistent connection open so the server can push data to clients the moment something happens.
Next.js does not expose an API for handling WebSocket upgrades. It is built around request-response cycles. In this step, you'll create a custom Node.js server that wraps Next.js and attaches Socket.IO for real-time bidirectional communication.
In this step, get ready to:
- Create a custom HTTP server that wraps the Next.js request handler.
- Attach a Socket.IO server to handle real-time WebSocket connections.
- Add event handlers for user tracking, messaging, and typing indicators.
Set up the server file and attach Socket.IO
The custom server pattern creates an HTTP server, hands all normal page requests to Next.js, and attaches Socket.IO to handle WebSocket connections on the same port.
- Create a new file called server.ts in the root of your realtime-chat folder (the same level as package.json).
- Paste the following code into it:
import { createServer } from "http";
import { parse } from "url";
import next from "next";
import { Server } from "socket.io";
// Configure the server environment
const dev = process.env.NODE_ENV !== "production";
const hostname = "localhost";
const port = parseInt(process.env.PORT || "3000", 10);
// Initialize Next.js
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
// Define the shape of a connected user
interface User {
id: string;
username: string;
}
app.prepare().then(() => {
// Create an HTTP server that delegates all requests to Next.js
const httpServer = createServer((req, res) => {
const parsedUrl = parse(req.url!, true);
handle(req, res, parsedUrl);
});
// Attach Socket.IO to the same HTTP server
const io = new Server(httpServer, {
cors: {
origin: "*",
methods: ["GET", "POST"],
},
});
// Track connected users and who is typing
const users = new Map<string, User>();
const typingUsers = new Set<string>();
io.on("connection", (socket) => {
console.log(`Client connected: ${socket.id}`);
});
httpServer.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`);
});
});
What does this code do?
This file creates a single HTTP server that does two jobs at once.
- app.getRequestHandler() returns a function that processes every incoming HTTP request through Next.js (pages, API routes, static assets).
- new Server(httpServer) attaches Socket.IO to the same server. Socket.IO intercepts WebSocket upgrade requests while letting normal HTTP traffic pass through to Next.js.
- The users Map and typingUsers Set store server-side state for all connected clients.
Why a custom server instead of Route Handlers?
Next.js Route Handlers follow the HTTP request-response model. Each request gets one response and the connection closes. WebSockets need the connection to stay open indefinitely so the server can push data at any time.
A custom server is the documented escape hatch for this. It gives you full control over the Node.js HTTP server so you can handle WebSocket upgrades alongside normal page requests.
- Save server.ts.
You won't see a change yet. The next substep adds the event handlers that bring this server to life.
Seeing a red underline on imports?
Make sure you installed @types/node in Step 1. Without it, TypeScript cannot find type definitions for http and url.
Help me fix TypeScript import errors in my server.ts file.
Handle user connections and messages
When a user connects, the server needs to track their username, broadcast messages to everyone, and clean up when they leave.
- In server.ts, find this line inside the io.on("connection") callback:
console.log(`Client connected: ${socket.id}`);
- Add the following event handlers directly below that line:
// When a user joins, store their info and notify everyone
socket.on("user:join", (username: string) => {
users.set(socket.id, { id: socket.id, username });
io.emit("users:update", Array.from(users.values()));
io.emit("message:new", {
id: Date.now().toString(),
text: `${username} joined the chat`,
sender: "system",
timestamp: Date.now(),
});
});
// When a user sends a message, broadcast it to all clients
socket.on("message:send", (text: string) => {
const user = users.get(socket.id);
if (!user) return;
io.emit("message:new", {
id: `${Date.now()}-${socket.id}`,
text,
sender: user.username,
timestamp: Date.now(),
});
});
// When a user disconnects, clean up and notify others
socket.on("disconnect", () => {
const user = users.get(socket.id);
if (user) {
typingUsers.delete(user.username);
users.delete(socket.id);
io.emit("users:update", Array.from(users.values()));
io.emit("message:new", {
id: Date.now().toString(),
text: `${user.username} left the chat`,
sender: "system",
timestamp: Date.now(),
});
io.emit("typing:update", Array.from(typingUsers));
}
console.log(`Client disconnected: ${socket.id}`);
});
What does this code do?
- user:join stores the username in the users Map, then broadcasts the updated user list and a system message to every connected client.
- message:send looks up who sent the message and broadcasts it with their username attached.
- disconnect removes the user from all tracking state (users Map and typingUsers Set) and notifies everyone they left.
- io.emit() sends an event to ALL connected sockets. This is how one user's action becomes visible to everyone else instantly.
- Save server.ts.
The typing indicators in the next substep complete the server.
Seeing a syntax error after pasting?
Make sure the new code sits inside the io.on("connection", (socket) => { ... }) block, after the console.log line but before the closing }); of the connection callback.
Help me fix my server.ts syntax error.
Add typing indicator support
Typing indicators show other users that someone is actively composing a message. The server tracks who is typing and broadcasts that state to everyone else.
- In server.ts, find the socket.on("disconnect") handler you just added.
- Add these two handlers directly ABOVE the disconnect handler:
// Broadcast typing status to other clients (not the sender)
socket.on("typing:start", () => {
const user = users.get(socket.id);
if (!user) return;
typingUsers.add(user.username);
socket.broadcast.emit("typing:update", Array.from(typingUsers));
});
socket.on("typing:stop", () => {
const user = users.get(socket.id);
if (!user) return;
typingUsers.delete(user.username);
socket.broadcast.emit("typing:update", Array.from(typingUsers));
});
What does this code do?
- typing:start adds the user's name to the typingUsers Set when they begin typing.
- typing:stop removes them when they pause.
- socket.broadcast.emit() sends the event to all clients EXCEPT the sender. You don't need to see your own typing indicator.
- Save server.ts.
- Verify the server compiles and starts by running:
npm run dev
- You should see > Ready on http://localhost:3000 in your terminal after a few seconds.
- Press Ctrl+C to stop the server for now.
Server not starting?
Check that your package.json has the dev script set to tsx --watch server.ts from Step 1.
If you see a port conflict error, another process is using port 3000. Close other terminal tabs or browser dev servers that might be running.
Help me debug my server startup error.
✔️ Awesome, I've got everything!
Great. Double check you've saved server.ts and move on to the next step.
ⓧ I'd like to double check the full code
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 hostname = "localhost";
const port = parseInt(process.env.PORT || "3000", 10);
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
interface User {
id: string;
username: string;
}
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: "*",
methods: ["GET", "POST"],
},
});
const users = new Map<string, User>();
const typingUsers = new Set<string>();
io.on("connection", (socket) => {
console.log(`Client connected: ${socket.id}`);
socket.on("user:join", (username: string) => {
users.set(socket.id, { id: socket.id, username });
io.emit("users:update", Array.from(users.values()));
io.emit("message:new", {
id: Date.now().toString(),
text: `${username} joined the chat`,
sender: "system",
timestamp: Date.now(),
});
});
socket.on("message:send", (text: string) => {
const user = users.get(socket.id);
if (!user) return;
io.emit("message:new", {
id: `${Date.now()}-${socket.id}`,
text,
sender: user.username,
timestamp: Date.now(),
});
});
socket.on("typing:start", () => {
const user = users.get(socket.id);
if (!user) return;
typingUsers.add(user.username);
socket.broadcast.emit("typing:update", Array.from(typingUsers));
});
socket.on("typing:stop", () => {
const user = users.get(socket.id);
if (!user) return;
typingUsers.delete(user.username);
socket.broadcast.emit("typing:update", Array.from(typingUsers));
});
socket.on("disconnect", () => {
const user = users.get(socket.id);
if (user) {
typingUsers.delete(user.username);
users.delete(socket.id);
io.emit("users:update", Array.from(users.values()));
io.emit("message:new", {
id: Date.now().toString(),
text: `${user.username} left the chat`,
sender: "system",
timestamp: Date.now(),
});
io.emit("typing:update", Array.from(typingUsers));
}
console.log(`Client disconnected: ${socket.id}`);
});
});
httpServer.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`);
});
});
Your WebSocket server is ready to handle connections, messages, and typing indicators. Next up, you'll build the React hook that connects the frontend to this server.
Build the useSocket Hook
Your server can accept WebSocket connections and broadcast events, but the React frontend has no way to tap into them yet. The missing piece is a bridge between Socket.IO's event system and React's state management.
In this step, you'll build a custom React hook called useSocket that manages the entire WebSocket lifecycle. It handles connecting, listening for server events, syncing data into React state, and cleaning up when the component unmounts.
In this step, get ready to:
- Define TypeScript interfaces for messages and users.
- Establish a socket connection that syncs with React state.
- Expose reusable functions for sending messages and typing indicators.
Create the hook file and define types
Custom hooks in React let you extract reusable logic into a single function. Your useSocket hook will own all Socket.IO logic so that no component ever touches the socket directly.
- In the VS Code Explorer sidebar, right-click the src folder and select New Folder.
- Name the folder hooks.
- Right-click the new hooks folder and select New File.
- Name the file useSocket.ts.
- Add the following code to useSocket.ts:
"use client";
import { useEffect, useState, useRef, useCallback } from "react";
import { io, Socket } from "socket.io-client";
interface Message {
id: string;
text: string;
sender: string;
timestamp: number;
}
interface User {
id: string;
username: string;
}
What does this code do?
The "use client" directive tells Next.js this file runs in the browser (not on the server). Hooks that use browser APIs like WebSockets must be client-side.
The Message interface defines the shape of every chat message (who sent it, what they said, when). The User interface defines each connected user's identity.
Add the hook function with state and event listeners
The hook function creates a socket connection the moment the component mounts. It listens for five server events and routes each one into a React state variable.
- Add the following code below the User interface in useSocket.ts:
export function useSocket(username: string) {
const [isConnected, setIsConnected] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
const socket = io();
socket.on("connect", () => {
setIsConnected(true);
socket.emit("user:join", username);
});
socket.on("disconnect", () => {
setIsConnected(false);
});
socket.on("message:new", (message: Message) => {
setMessages((prev) => [...prev, message]);
});
socket.on("users:update", (updatedUsers: User[]) => {
setUsers(updatedUsers);
});
socket.on("typing:update", (typing: string[]) => {
setTypingUsers(typing.filter((u) => u !== username));
});
socketRef.current = socket;
return () => {
socket.disconnect();
};
}, [username]);
const sendMessage = useCallback((text: string) => {
if (socketRef.current) {
socketRef.current.emit("message:send", text);
}
}, []);
const sendTyping = useCallback((isTyping: boolean) => {
if (socketRef.current) {
socketRef.current.emit(isTyping ? "typing:start" : "typing:stop");
}
}, []);
return { isConnected, messages, users, typingUsers, sendMessage, sendTyping };
}
What does this code do?
- io() creates a Socket.IO client connection to the same origin the page was served from (your custom server).
- On connect, the hook immediately emits user:join so the server knows who connected.
- Each event listener (message:new, users:update, typing:update) pushes data into a React state variable, which triggers re-renders.
- socketRef stores the socket instance in a ref so the callbacks can access it without causing re-renders.
- The cleanup function (return () => { socket.disconnect() }) runs when the component unmounts to avoid memory leaks.
Why useCallback for sendMessage and sendTyping?
Wrapping these functions in useCallback keeps their reference stable across re-renders. Without it, any component using the hook would re-render on every message because its props appear to change.
Understand the hook's public API
Components that use this hook never import Socket.IO directly. They interact with WebSockets entirely through the values and functions the hook returns.
What does the hook expose?
- isConnected — a boolean that reflects whether the socket is live.
- messages — the full array of chat messages received so far.
- users — the list of currently online users.
- typingUsers — usernames of people currently typing (excluding yourself).
- sendMessage(text) — emits message:send to the server.
- sendTyping(isTyping) — emits typing:start or typing:stop depending on the boolean you pass.
- Save src/hooks/useSocket.ts.
You won't see a visible change in the browser yet because no component imports this hook. The next step builds the chat UI that calls useSocket to bring everything to life.
Seeing a red underline on the import?
If VS Code shows an error on import { io, Socket } from "socket.io-client", make sure you ran npm install socket.io-client@4.8.3 in the previous step. The package must be in your node_modules for TypeScript to resolve it.
help me fix a Socket.IO import error in my useSocket hook
✔️ Awesome, I've got everything!
Great. Double check you've saved src/hooks/useSocket.ts before moving on.
ⓧ I'd like to double check the full code
"use client";
import { useEffect, useState, useRef, useCallback } from "react";
import { io, Socket } from "socket.io-client";
interface Message {
id: string;
text: string;
sender: string;
timestamp: number;
}
interface User {
id: string;
username: string;
}
export function useSocket(username: string) {
const [isConnected, setIsConnected] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
const socket = io();
socket.on("connect", () => {
setIsConnected(true);
socket.emit("user:join", username);
});
socket.on("disconnect", () => {
setIsConnected(false);
});
socket.on("message:new", (message: Message) => {
setMessages((prev) => [...prev, message]);
});
socket.on("users:update", (updatedUsers: User[]) => {
setUsers(updatedUsers);
});
socket.on("typing:update", (typing: string[]) => {
setTypingUsers(typing.filter((u) => u !== username));
});
socketRef.current = socket;
return () => {
socket.disconnect();
};
}, [username]);
const sendMessage = useCallback((text: string) => {
if (socketRef.current) {
socketRef.current.emit("message:send", text);
}
}, []);
const sendTyping = useCallback((isTyping: boolean) => {
if (socketRef.current) {
socketRef.current.emit(isTyping ? "typing:start" : "typing:stop");
}
}, []);
return { isConnected, messages, users, typingUsers, sendMessage, sendTyping };
}
Your hook is ready to go. Next up, you'll build the chat interface that imports useSocket and renders messages, online users, and typing indicators in real time.
Build the Chat Interface
Your server handles connections and your hook manages WebSocket state. Now you need a UI that ties everything together.
In this step, you will build two components. The first is a simple join form where users pick a username. The second is the full chat room with a live message feed, an online users sidebar, and a typing indicator.
In this step, get ready to:
- Create the username entry screen for joining the chat.
- Build the ChatRoom component with live messages and online users.
- Add a message input form with typing indicators.
Create the username entry screen
Before a user can chat, they need to pick a username. The entry page acts as a gatekeeper. Once a username is submitted, the app swaps the form for the full chat room.
- In VS Code, open src/app/page.tsx.
- Select all the existing content and replace it with the following code:
"use client";
import { useState } from "react";
import { ChatRoom } from "@/components/ChatRoom";
export default function Home() {
const [username, setUsername] = useState("");
const [joined, setJoined] = useState(false);
// Handle the join form submission
const handleJoin = (e: React.FormEvent) => {
e.preventDefault();
if (username.trim()) {
setJoined(true);
}
};
// Once joined, render the ChatRoom instead of the form
if (joined) {
return <ChatRoom username={username} />;
}
return (
<main className="flex min-h-screen items-center justify-center bg-gray-900">
<form
onSubmit={handleJoin}
className="bg-gray-800 p-8 rounded-lg shadow-lg w-full max-w-sm"
>
<h1 className="text-2xl font-bold text-white mb-6 text-center">
Join Chat
</h1>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
className="w-full p-3 rounded bg-gray-700 text-white placeholder-gray-400 border border-gray-600 focus:border-blue-500 focus:outline-none mb-4"
/>
<button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded transition-colors"
>
Join
</button>
</form>
</main>
);
}
What does this code do?
This is a Client Component (marked with "use client") because it needs browser-side interactivity like state and event handlers.
- username stores what the user types. joined tracks whether they have submitted the form.
- handleJoin prevents the default form reload, checks the username is not empty, and flips joined to true.
- Once joined is true, the entire form unmounts and the ChatRoom component renders in its place with the chosen username.
- Save src/app/page.tsx.
You will not see this page working yet because it imports ChatRoom, which does not exist. You will create it next.
Build the ChatRoom component
The ChatRoom component is the heart of the app. It connects to the WebSocket server through the useSocket hook, manages local input state, and renders the message feed with auto-scroll.
- Create a new folder called components inside src/ by running:
mkdir src/components
- Create a new file called ChatRoom.tsx inside src/components/ by running:
touch src/components/ChatRoom.tsx
- Confirm the file exists by running:
ls src/components
You should see ChatRoom.tsx in the output.
- Open src/components/ChatRoom.tsx in VS Code and paste the following code to set up the component logic:
"use client";
import { useState, useEffect, useRef } from "react";
import { useSocket } from "@/hooks/useSocket";
interface ChatRoomProps {
username: string;
}
export function ChatRoom({ username }: ChatRoomProps) {
// Connect to the WebSocket server and get state + actions
const { isConnected, messages, users, typingUsers, sendMessage, sendTyping } =
useSocket(username);
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Auto-scroll to the latest message whenever messages update
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Send the message and reset input state
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim()) {
sendMessage(input.trim());
setInput("");
sendTyping(false);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
}
};
}
What does this code do?
- useSocket(username) opens the WebSocket connection and returns real-time state (messages, users, typing status) plus action functions.
- messagesEndRef is a ref attached to an invisible div at the bottom of the message list. The useEffect scrolls to it every time a new message arrives.
- handleSubmit sends the trimmed message, clears the input field, stops the typing indicator, and cancels any pending typing timeout.
Add the message input form with a typing indicator
Typing indicators give users a sense of presence. When someone is composing a message, other users see a subtle hint that a reply is coming.
- In src/components/ChatRoom.tsx, add the handleInputChange function directly below the closing } of handleSubmit:
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
sendTyping(true);
// Clear any existing timeout so we don't fire stop prematurely
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// After 1 second of no typing, tell the server we stopped
typingTimeoutRef.current = setTimeout(() => {
sendTyping(false);
}, 1000);
};
What is the debounce pattern doing here?
Every keystroke emits typing:start to the server. But you only want to emit typing:stop once the user has paused for a full second.
Each keystroke resets the 1000ms timer. Only when the user stops typing for a full second does the timeout fire and send typing:stop. This avoids flooding the server with rapid start/stop events.
- Now add the return statement below handleInputChange to render the full chat interface. Paste this code as the return of the ChatRoom function:
return (
<div className="flex h-screen bg-gray-900">
{/* Sidebar */}
<aside className="w-64 bg-gray-800 p-4 border-r border-gray-700">
<h2 className="text-lg font-semibold text-white mb-4">
Online ({users.length})
</h2>
<ul className="space-y-2">
{users.map((user) => (
<li key={user.id} className="flex items-center gap-2 text-gray-300">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
{user.username}
{user.username === username && (
<span className="text-xs text-gray-500">(you)</span>
)}
</li>
))}
</ul>
</aside>
{/* Chat Area */}
<main className="flex-1 flex flex-col">
{/* Header */}
<header className="bg-gray-800 p-4 border-b border-gray-700 flex items-center justify-between">
<h1 className="text-xl font-bold text-white">Chat Room</h1>
<span
className={`text-sm ${isConnected ? "text-green-400" : "text-red-400"}`}
>
{isConnected ? "Connected" : "Disconnected"}
</span>
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className={`${
msg.sender === "system"
? "text-center text-gray-500 text-sm italic"
: msg.sender === username
? "text-right"
: "text-left"
}`}
>
{msg.sender !== "system" && (
<div className="inline-block max-w-xs lg:max-w-md">
<p className="text-xs text-gray-400 mb-1">{msg.sender}</p>
<p
className={`px-4 py-2 rounded-lg ${
msg.sender === username
? "bg-blue-600 text-white"
: "bg-gray-700 text-gray-200"
}`}
>
{msg.text}
</p>
</div>
)}
{msg.sender === "system" && <p>{msg.text}</p>}
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Typing Indicator */}
{typingUsers.length > 0 && (
<div className="px-4 py-1 text-sm text-gray-400 italic">
{typingUsers.join(", ")}{" "}
{typingUsers.length === 1 ? "is" : "are"} typing...
</div>
)}
{/* Input */}
<form
onSubmit={handleSubmit}
className="p-4 border-t border-gray-700 bg-gray-800"
>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={handleInputChange}
placeholder="Type a message..."
className="flex-1 p-3 rounded bg-gray-700 text-white placeholder-gray-400 border border-gray-600 focus:border-blue-500 focus:outline-none"
/>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-6 py-3 rounded transition-colors"
>
Send
</button>
</div>
</form>
</main>
</div>
);
What is this JSX rendering?
- The sidebar lists all online users with a green dot. Your own name gets a "(you)" label.
- The header shows the room title and a live connection status that turns green or red.
- Messages render in three styles: system messages (centered and italic), your own messages (right-aligned blue bubble), and other users' messages (left-aligned gray bubble).
- The typing indicator only appears when at least one other user is actively composing a message.
- The invisible <div ref={messagesEndRef} /> is the scroll anchor. The useEffect scrolls to it whenever a new message arrives.
- Save src/components/ChatRoom.tsx.
You will not see the app running yet because the dev server is not started. You will launch it in the next step and verify everything works together.
Seeing a red underline on the ChatRoom import?
If VS Code shows an error on the import { ChatRoom } from "@/components/ChatRoom" line in page.tsx, make sure you saved ChatRoom.tsx and that the file is at exactly src/components/ChatRoom.tsx. The path alias @/ maps to the src/ folder.
Help me fix my ChatRoom import error.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/app/page.tsx and src/components/ChatRoom.tsx.
ⓧ I'd like to double check the full code
Here is the full src/app/page.tsx:
"use client";
import { useState } from "react";
import { ChatRoom } from "@/components/ChatRoom";
export default function Home() {
const [username, setUsername] = useState("");
const [joined, setJoined] = useState(false);
const handleJoin = (e: React.FormEvent) => {
e.preventDefault();
if (username.trim()) {
setJoined(true);
}
};
if (joined) {
return <ChatRoom username={username} />;
}
return (
<main className="flex min-h-screen items-center justify-center bg-gray-900">
<form
onSubmit={handleJoin}
className="bg-gray-800 p-8 rounded-lg shadow-lg w-full max-w-sm"
>
<h1 className="text-2xl font-bold text-white mb-6 text-center">
Join Chat
</h1>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
className="w-full p-3 rounded bg-gray-700 text-white placeholder-gray-400 border border-gray-600 focus:border-blue-500 focus:outline-none mb-4"
/>
<button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded transition-colors"
>
Join
</button>
</form>
</main>
);
}
Here is the full src/components/ChatRoom.tsx:
"use client";
import { useState, useEffect, useRef } from "react";
import { useSocket } from "@/hooks/useSocket";
interface ChatRoomProps {
username: string;
}
export function ChatRoom({ username }: ChatRoomProps) {
const { isConnected, messages, users, typingUsers, sendMessage, sendTyping } =
useSocket(username);
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim()) {
sendMessage(input.trim());
setInput("");
sendTyping(false);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
sendTyping(true);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
sendTyping(false);
}, 1000);
};
return (
<div className="flex h-screen bg-gray-900">
{/* Sidebar */}
<aside className="w-64 bg-gray-800 p-4 border-r border-gray-700">
<h2 className="text-lg font-semibold text-white mb-4">
Online ({users.length})
</h2>
<ul className="space-y-2">
{users.map((user) => (
<li key={user.id} className="flex items-center gap-2 text-gray-300">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
{user.username}
{user.username === username && (
<span className="text-xs text-gray-500">(you)</span>
)}
</li>
))}
</ul>
</aside>
{/* Chat Area */}
<main className="flex-1 flex flex-col">
{/* Header */}
<header className="bg-gray-800 p-4 border-b border-gray-700 flex items-center justify-between">
<h1 className="text-xl font-bold text-white">Chat Room</h1>
<span
className={`text-sm ${isConnected ? "text-green-400" : "text-red-400"}`}
>
{isConnected ? "Connected" : "Disconnected"}
</span>
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className={`${
msg.sender === "system"
? "text-center text-gray-500 text-sm italic"
: msg.sender === username
? "text-right"
: "text-left"
}`}
>
{msg.sender !== "system" && (
<div className="inline-block max-w-xs lg:max-w-md">
<p className="text-xs text-gray-400 mb-1">{msg.sender}</p>
<p
className={`px-4 py-2 rounded-lg ${
msg.sender === username
? "bg-blue-600 text-white"
: "bg-gray-700 text-gray-200"
}`}
>
{msg.text}
</p>
</div>
)}
{msg.sender === "system" && <p>{msg.text}</p>}
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Typing Indicator */}
{typingUsers.length > 0 && (
<div className="px-4 py-1 text-sm text-gray-400 italic">
{typingUsers.join(", ")}{" "}
{typingUsers.length === 1 ? "is" : "are"} typing...
</div>
)}
{/* Input */}
<form
onSubmit={handleSubmit}
className="p-4 border-t border-gray-700 bg-gray-800"
>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={handleInputChange}
placeholder="Type a message..."
className="flex-1 p-3 rounded bg-gray-700 text-white placeholder-gray-400 border border-gray-600 focus:border-blue-500 focus:outline-none"
/>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-6 py-3 rounded transition-colors"
>
Send
</button>
</div>
</form>
</main>
</div>
);
}
Your chat interface is built. Next up, you will start the dev server and test real-time communication across multiple browser tabs.
Test Real-Time Communication
All four files are in place. Your custom server, the useSocket hook, the join screen, and the chat room component are ready to work together.
Now you get to see WebSockets in action. You'll simulate multiple users by opening the chat app in separate browser tabs and watching messages flow instantly between them.
In this step, get ready to:
- Start the development server and join the chat from two browser tabs.
- Send messages and verify the typing indicator works across tabs.
- Test disconnect and reconnection behavior.
Start the server and connect two users
Your custom server.ts replaces the default Next.js dev server. When you run it, it boots Next.js AND attaches Socket.IO to the same HTTP server on port 3000.
- Start the development server by running this command in your terminal:
npm run dev
The server compiles your Next.js app and then prints a ready message. This may take 10-20 seconds the first time.
What should I see?
Your terminal should display > Ready on http://localhost:3000 once the server is ready to accept connections.
Seeing a port conflict error?
If port 3000 is already in use, stop whatever process is using it (often a leftover Next.js dev server). On macOS/Linux, run lsof -i :3000 to find the process ID, then kill <PID>.
If you see a TypeScript compilation error, double-check that server.ts is saved in the project root (not inside src/).
Help me debug my server startup error.
- Open your browser and navigate to http://localhost:3000.
- You should see the Join Chat form with a username input and a blue Join button.
- Type a username (for example, Alice) into the input field.
- Click Join.
The join form disappears and you see the full chat interface. The sidebar shows Online (1) with your username listed. The header shows a green Connected status.
- Open a second browser tab and navigate to http://localhost:3000.
- Type a different username (for example, Bob) and click Join.
Both tabs now show Online (2) in the sidebar with both usernames listed. In the message area, you should see system messages like Alice joined the chat and Bob joined the chat.
Why do both tabs update at the same time?
When Bob joins, the server broadcasts a users:update event to every connected socket. Both Alice's and Bob's browser tabs receive this event through their persistent WebSocket connection and re-render the sidebar.
No polling, no page refresh. The server pushes data the instant something changes.
Send messages and test the typing indicator
With two users connected, you can test the core chat functionality. Messages should appear instantly in both tabs without any refresh.
- Switch to the first tab (Alice).
- Type Hello from Alice! in the message input at the bottom.
- Click Send or press Enter.
The message appears as a blue bubble on the right side of Alice's tab. It appears as a gray bubble on the left side of Bob's tab.
- Switch to the second tab (Bob).
- Type Hey Alice! and press Enter.
Bob's message now appears in both tabs instantly. Alice sees it as a gray bubble on the left.
- Now test the typing indicator. Switch to Bob's tab and start typing in the message input (don't send yet).
- Switch to Alice's tab while the text is still unsent.
You should see Bob is typing... appear just above the input area in Alice's tab. After about one second of inactivity, the indicator disappears.
How does the typing indicator work?
Every keystroke emits a typing:start event. The handleInputChange function then sets a 1000ms debounce timer. If no new keystrokes arrive within that window, it emits typing:stop.
The server broadcasts these events to all other connected clients (excluding the sender), so only other users see the typing indicator.
Test disconnect and reconnect behavior
A real chat app needs to handle users leaving and returning gracefully. Your server tracks connections and broadcasts presence changes automatically.
- Close Bob's browser tab (the second tab).
- Switch to Alice's tab.
You should see two changes in Alice's tab. The sidebar updates to Online (1) with only Alice listed. A system message appears saying Bob left the chat.
What triggers the disconnect?
When you close the browser tab, the WebSocket connection is severed. Socket.IO fires the disconnect event on the server. Your server then removes the user from the users Map and broadcasts the updated user list to all remaining clients.
- Open a new browser tab and navigate to http://localhost:3000.
- Type Bob again and click Join.
Both tabs show Online (2) again. Alice's tab displays a new system message: Bob joined the chat. The reconnection works because the client creates a fresh socket connection each time the useSocket hook mounts.
Online list not updating?
If closing a tab doesn't remove the user, check your terminal for the Client disconnected log message. If it doesn't appear, the browser may still be holding the connection open (common with browser dev tools network throttling).
If the user list shows duplicates after rejoining, make sure your server.ts uses socket.id as the Map key. Each new connection gets a unique ID, so old entries are automatically replaced.
Help me debug my online user list.
- Check your terminal where the server is running.
You should see log entries for each connection and disconnection, confirming that the server tracked every event correctly.
Secret mission
Add Multiple Chat Rooms
Right now, every user lands in the same single chat room. Real chat applications like Slack and Discord organize conversations into channels. Can you add room support so users can switch between General, Tech, and Random without losing their connection?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about.
Decide whether to keep your project files for reference, pause the running server to free up resources, or delete everything entirely.
Resources you used:
- The realtime-chat project directory on your Desktop (contains all source code and installed packages).
- The development server process running in your terminal.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the chat app.
- Your development server continues running at http://localhost:3000.
- All project files remain in the realtime-chat directory on your Desktop.
Pause - I'll come back to this later
Stop the running server to free up your terminal and system resources. Your files stay on disk so you can pick up where you left off.
- Switch to the terminal where your development server is running.
- Press Ctrl+C to stop the server.
- When you want to resume, open a terminal in the realtime-chat directory and run npm run dev.
Delete - I don't want to use this again
Remove all project files and start fresh. This permanently deletes your code and installed packages.
- Switch to the terminal where your development server is running.
- Press Ctrl+C to stop the server.
- Navigate to your Desktop and delete the project directory by running these commands:
macOS
cd ~/Desktop
rm -rf realtime-chat
Windows
cd ~\Desktop
Remove-Item -Recurse -Force realtime-chat
Confirm the folder is gone by running:
ls
You should no longer see realtime-chat in the output.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional real-time chat application where messages appear instantly across browser tabs, powered by WebSockets and a custom Node.js server.
You've learned how to:
- Created a custom server that wraps Next.js with Socket.IO to enable persistent WebSocket connections alongside standard HTTP request handling.
- Built a reusable useSocket hook that manages connection lifecycle, real-time message state, and typing indicators using React's useEffect, useRef, and useCallback.
- Implemented event-driven architecture with online user presence tracking, typing indicators with debounce, and system messages for join/leave events.
- Secret Mission: Added multiple chat rooms with per-room state management using Socket.IO's built-in room system, allowing users to switch rooms without dropping their WebSocket connection.
Ready to quiz yourself?