Build a Real-Time Chatroom

Wire a React frontend to a Node.js backend with Socket.IO WebSockets.

Introduction

30 Second Summary

Think about the last time you sent a message in a group chat. You typed, hit send, and everyone saw it appear instantly without refreshing the page.

In this project, you will build a real-time chatroom using React and Socket.IO. You will wire a React frontend to a Node.js server over WebSockets so messages appear instantly across multiple browser tabs.

What You'll Build

Open your chatroom in two browser tabs, pick a username in each, and watch messages fly between them in real time with zero page refreshes.

By the end of this project, you'll have:

  • A live chatroom where you type a message in one browser tab and it appears in another tab instantly.
  • A join screen that lets you pick a username before entering the room, so every message shows who sent it.
  • An online users sidebar that updates in real time as people join and leave the chatroom.
  • Secret Mission: Add live typing indicators that show when another user is typing a message.

Are there any prerequisites?

This project assumes you are familiar with React (components, hooks, state). You also need Node.js v20.19 or higher installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Client and Server

Real-time applications need two separate pieces: a frontend the user interacts with, and a backend that coordinates messages between everyone.

In this step, you will scaffold a React client and a Node.js server as two independent projects. Then you will confirm they can talk to each other over a WebSocket connection.

In this step, get ready to:
  • Scaffold a React client with the Socket.IO client library installed.
  • Create a Node.js server with Express and Socket.IO that logs connections.
  • Verify the WebSocket bridge works between both projects.
Scaffold the React client

The client is a Vite-powered React app that will connect to the server using the Socket.IO client library.

  • 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
  • Scaffold the React project by running this command:
npm create vite@latest chatroom-client -- --template react

What does this command do?

The npm create vite@latest command scaffolds a new Vite project. The --template react flag pre-selects React so you skip the interactive template menu.

Allow the install to proceed

npm may prompt you with "Need to install the following packages. Ok to proceed? (y)" before running the scaffolder. Type y and press Enter to continue.

  • Move into the new project and install its base dependencies by running these commands:
cd chatroom-client
npm install
  • Install the Socket.IO client library by running:
npm install socket.io-client

What is socket.io-client?

This is the browser-side half of Socket.IO. It opens a persistent WebSocket connection to a server and gives you an event-driven API (emit to send, on to listen) instead of raw low-level WebSocket frames.

  • Start the Vite dev server to confirm everything works by running:
npm run dev

You should see Vite print a local URL at http://localhost:5173.

  • Open http://localhost:5173 in your browser.

You should see the default Vite + React starter page with a spinning logo.

Don't see the Vite page?

  • Make sure you ran npm install before npm run dev. Without installing dependencies first, Vite cannot start.
  • Check your terminal output for errors. If port 5173 is already in use, Vite picks the next available port and prints the actual URL.

Help me troubleshoot the Vite dev server not starting.

Create the server project

The server is a separate Node.js project that uses Express for HTTP and Socket.IO for WebSocket connections. It runs on a different port from the client, just like production apps.

  • Open a second terminal window. Leave the first terminal running npm run dev for the client.
  • Navigate to your Desktop and create the server folder by running these commands:
cd ~/Desktop
mkdir chatroom-server
cd chatroom-server
  • Confirm the folder was created by running:
ls ../chatroom-server

You should see an empty directory listing (no error), which confirms the folder exists.

  • Open the chatroom-server folder in your code editor.
  • Create a new file called package.json in the chatroom-server folder with this content:
{
  "name": "chatroom-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  }
}

Why type: module?

Setting "type": "module" tells Node.js to use ES module syntax (import/export) instead of the older require() style. This matches how the React client writes imports and keeps both projects consistent.

  • Save package.json.
  • Install Express and Socket.IO by running this command in the second terminal:
npm install express@4 socket.io

You should see npm download both packages and print an "added X packages" confirmation.

Why express@4?

Socket.IO's documentation and tutorials use Express 4. Pinning to express@4 ensures compatibility with all documented patterns.

npm install failed?

  • Make sure you are inside the chatroom-server folder. Run pwd (macOS/Linux) or cd (Windows) to check your current directory.
  • Confirm package.json exists in the folder. Run ls and check it appears in the listing.

Help me fix npm install errors in my chatroom-server folder.

  • Create a new file called index.js in the chatroom-server folder with this content:
import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";

const app = express();
const server = createServer(app);
const io = new Server(server, {
  cors: {
    origin: "http://localhost:5173",
  },
});

io.on("connection", (socket) => {
  console.log("a user connected:", socket.id);
});

server.listen(3000, () => {
  console.log("server running at http://localhost:3000");
});

What does this code do?

  • Express creates an HTTP app, but Socket.IO needs a raw HTTP server. That is why createServer(app) wraps Express before passing it to Socket.IO.
  • The cors option allows the React client at http://localhost:5173 to connect. Without this, the browser blocks cross-origin WebSocket requests.
  • The connection event fires every time a new client connects, logging their unique socket ID.
  • Save index.js.
  • Start the server by running this command in the second terminal:
node index.js

You should see server running at http://localhost:3000 printed in your terminal.

Seeing "Cannot find module" or a syntax error?

  • "Cannot find module" means npm install did not run successfully. Run npm install again inside chatroom-server.
  • "Cannot use import statement" means package.json is missing "type": "module". Open package.json and confirm the type field is present.

Help me fix my Socket.IO server not starting.

✔️ Awesome, I've got everything!

Great. Make sure index.js and package.json are both saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete index.js:

import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";

const app = express();
const server = createServer(app);
const io = new Server(server, {
  cors: {
    origin: "http://localhost:5173",
  },
});

io.on("connection", (socket) => {
  console.log("a user connected:", socket.id);
});

server.listen(3000, () => {
  console.log("server running at http://localhost:3000");
});

Here is the complete package.json (after npm install added the dependencies):

{
  "name": "chatroom-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "^4.21.2",
    "socket.io": "^4.8.3"
  }
}
Verify the connection bridge

Both projects are running, but they are not talking to each other yet. You need to add a Socket.IO connection on the client side and confirm the handshake succeeds.

  • In your code editor, open chatroom-client/src/App.jsx.
  • Replace the entire file contents with this code:
import { useEffect } from "react";
import { io } from "socket.io-client";
import "./App.css";

// Connect to the Socket.IO server on port 3000
const socket = io("http://localhost:3000");

function App() {
  useEffect(() => {
    // Log when the WebSocket connection succeeds
    socket.on("connect", () => {
      console.log("connected");
    });
    return () => {
      socket.off("connect");
    };
  }, []);

  return <div>Chatroom</div>;
}

export default App;

What does this code do?

  • The io("http://localhost:3000") call creates a Socket.IO client instance that immediately attempts to connect to the server.
  • Inside useEffect, you register a listener for the connect event. This fires once the WebSocket handshake completes.
  • The cleanup function (socket.off) removes the listener when the component unmounts, preventing memory leaks.
  • Save App.jsx.

Vite auto-reloads the browser when you save. Before you check the browser, predict: what do you expect to see in the browser console and the server terminal?

  • Open your browser at http://localhost:5173.
  • Open the browser's developer console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).

You should see connected printed in the browser console.

  • Switch to the second terminal (where node index.js is running).

You should see a user connected: followed by a socket ID. This confirms the React client and the Node.js server are communicating over WebSockets.

Don't see "connected" in the browser console?

  • Make sure the server is still running in the second terminal. If you stopped it, run node index.js again.
  • Check that the cors.origin value in index.js matches your Vite URL exactly: http://localhost:5173.
  • Look for a CORS error in the browser console (a red message mentioning "blocked by CORS"). This means the origin URL in the server config does not match.

Help me fix the Socket.IO connection between my client and server.

✔️ Awesome, I've got everything!

Great. Confirm you see connected in the browser console and a user connected in the server terminal before moving on.

ⓧ I'd like to double check the full code

Here is the complete App.jsx at this point:

import { useEffect } from "react";
import { io } from "socket.io-client";
import "./App.css";

const socket = io("http://localhost:3000");

function App() {
  useEffect(() => {
    socket.on("connect", () => {
      console.log("connected");
    });
    return () => {
      socket.off("connect");
    };
  }, []);

  return <div>Chatroom</div>;
}

export default App;

Your two projects are wired together. Next up, you will make them actually do something useful: sending and receiving chat messages in real time.

Send and Receive Messages

Your server and client are talking to each other over WebSockets. The connection log proves the bridge works. Now it is time to send actual data across that bridge.

The core promise of real-time communication is instant message delivery without page refreshes. In this step, you will type a message in one browser tab and watch it appear in another tab instantly.

In this step, get ready to:
  • Add a message broadcast handler on the server.
  • Build a chat UI in React with a message list and input form.
  • Test real-time messaging across two browser tabs.
Build the server broadcast

Right now the server logs connections but ignores everything a client sends. You need to teach it to listen for messages and relay them to every connected client.

  • In chatroom-server/index.js, find the console.log("a user connected:", socket.id); line inside the io.on("connection") callback.
  • Add the following code below that line:
  socket.on("chat-message", (text) => {
    // Broadcast the message to ALL connected clients
    io.emit("chat-message", text);
  });

What does this code do?

socket.on("chat-message") listens for any client that sends a chat-message event.

io.emit("chat-message", text) sends that message back out to every connected client, including the sender. The server does not store messages. It just relays them in real time.

  • Save index.js.
  • Switch to the terminal running your server. Press Ctrl+C to stop it, then restart it by running:
node index.js
  • Confirm you see server running at http://localhost:3000 in the terminal with no errors.

Seeing a syntax error?

Make sure the new socket.on("chat-message") block is inside the io.on("connection") callback, not outside it. The closing }); of the connection block must come after your new code.

Help me debug my Socket.IO server syntax error

Build the client UI

The server can now relay messages. The client needs a chat interface to send them and display incoming ones. You will replace the temporary connect-log component with a real chat UI.

  • Open chatroom-client/src/App.jsx and select all the existing code (Cmd+A on macOS or Ctrl+A on Windows).
  • Delete it and replace it with this code:
import { useEffect, useState, useRef } from "react";
import { io } from "socket.io-client";
import "./App.css";

const socket = io("http://localhost:3000");

function App() {
  // Track the current input value and all received messages
  const [message, setMessage] = useState("");
  const [messages, setMessages] = useState([]);
  // Ref for auto-scrolling to the latest message
  const messagesEndRef = useRef(null);

  useEffect(() => {
    // Handler for incoming messages from the server
    function onChatMessage(msg) {
      setMessages((prev) => [...prev, msg]);
    }
    socket.on("chat-message", onChatMessage);
    return () => {
      socket.off("chat-message", onChatMessage);
    };
  }, []);

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

What does this code do?

  • messages is an array that grows every time the server sends a chat-message event.
  • The first useEffect subscribes to incoming messages once on mount and cleans up on unmount.
  • The second useEffect auto-scrolls the message list to the bottom whenever a new message arrives.
  • Now add the rest of the component directly below the code you just pasted:
  function handleSend(e) {
    e.preventDefault();
    if (message.trim()) {
      // Send the message text to the server
      socket.emit("chat-message", message.trim());
      setMessage("");
    }
  }

  return (
    <div className="chat-container">
      <main className="chat-main">
        <div className="messages">
          {messages.map((msg, i) => (
            <div key={i} className="message">
              <span className="message-text">{msg}</span>
            </div>
          ))}
          <div ref={messagesEndRef} />
        </div>
        <form className="message-form" onSubmit={handleSend}>
          <input
            type="text"
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            placeholder="Type a message..."
            autoFocus
          />
          <button type="submit">Send</button>
        </form>
      </main>
    </div>
  );
}

export default App;

What does this code do?

  • handleSend fires on form submit. It emits a chat-message event to the server with the input text, then clears the input.
  • The JSX renders a message list that maps over the messages array and a form at the bottom for typing new messages.
  • The invisible div with messagesEndRef sits at the bottom of the list so the scroll effect always targets the newest message.
  • Save App.jsx.
  • Switch to the browser tab running your Vite dev server at http://localhost:5173. The page should hot-reload automatically.
  • You should see a text input at the bottom of the page with a Send button. The message area above is empty.

Page is blank or shows an error?

  • Check your browser console for a specific error message. A common cause is a missing closing brace or parenthesis between the two code blocks you pasted.
  • Make sure the export default App; line is at the very end of the file after the closing } of the App function.
  • Confirm the Vite dev server is still running in its terminal. If it stopped, run npm run dev again from the chatroom-client folder.

Help me fix my React chat UI rendering error

✔️ Awesome, I've got everything!

Great. Make sure you have saved App.jsx and your browser shows the input form.

ⓧ I'd like to double check the full code

Here is the complete App.jsx file. Scroll up and confirm your file contains both code blocks in order: the imports and effects first, then the handleSend function and JSX return second. The file should end with export default App;.

Here is the full chatroom-server/index.js for reference:

import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";

const app = express();
const server = createServer(app);
const io = new Server(server, {
  cors: {
    origin: "http://localhost:5173",
  },
});

io.on("connection", (socket) => {
  console.log("a user connected:", socket.id);

  socket.on("chat-message", (text) => {
    io.emit("chat-message", text);
  });
});

server.listen(3000, () => {
  console.log("server running at http://localhost:3000");
});
Test with two tabs

A single tab can send and receive its own messages. The real test is whether two independent clients see each other's messages instantly.

  • Open a second browser tab to http://localhost:5173.
  • Arrange both tabs so you can see them side by side.

Before you type anything, predict: when you send a message in one tab, will it appear in just that tab, or in both?

  • In the first tab, type hello from tab 1 and click Send.

The message appears in both tabs instantly. No page refresh, no polling. The server received the event and broadcast it to every connected client in real time.

  • In the second tab, type hello from tab 2 and click Send.
  • Confirm both tabs now show both messages.

Notice the problem: every message looks identical. You cannot tell who sent what. Both tabs display the same plain text with no names attached. That gap is exactly what Step 3 fixes.

Why can't I tell messages apart?

The server currently broadcasts raw text with no metadata. It has no concept of who sent the message. In the next step, you will add usernames so each message carries identity and each user's own messages are styled differently.

You have proven that real-time bidirectional messaging works. Messages fly between tabs with zero delay. Next up, you will add usernames and an online users sidebar so you can see who is in the room and whose message is whose.

Add Usernames and Online Users

Your chatroom relays messages instantly, but there is a problem: every message looks the same. You cannot tell who said what, and nobody knows who else is in the room.

In this step, you will add identity and presence to the chatroom. Each user picks a username before entering, and an Online Users sidebar shows who is in the room at any moment.

In this step, get ready to:
  • Add a username Map on the server that tracks who is connected.
  • Build a join screen where users pick a username before entering the chat.
  • Display an Online Users sidebar and style messages as attributed chat bubbles.
Add username tracking on the server

The server currently receives a raw text string and broadcasts it to everyone. It has no concept of who sent the message.

You need the server to store each connected user's chosen name, attach it to outgoing messages, and broadcast an updated list of online users whenever someone joins or leaves.

  • Switch to the terminal running your server.
  • Press Ctrl+C to stop the server.
  • In chatroom-server/index.js, find the existing io.on("connection") block and replace it (along with the line above it) with the following code:
const onlineUsers = new Map();

io.on("connection", (socket) => {
  console.log("a user connected:", socket.id);

  // Store username when a user joins
  socket.on("set-username", (username) => {
    onlineUsers.set(socket.id, username);
    io.emit("online-users", Array.from(onlineUsers.values()));
  });

  // Attach sender's username to each message
  socket.on("chat-message", (text) => {
    const username = onlineUsers.get(socket.id) || "Anonymous";
    io.emit("chat-message", {
      id: socket.id + Date.now(),
      username,
      text,
      timestamp: Date.now(),
    });
  });

  // Remove user on disconnect and notify everyone
  socket.on("disconnect", () => {
    onlineUsers.delete(socket.id);
    io.emit("online-users", Array.from(onlineUsers.values()));
    console.log("user disconnected:", socket.id);
  });
});

What does this code do?

The onlineUsers Map stores a socketId -> username pair for every connected user.

  • set-username saves the chosen name and broadcasts the full online list to all clients using io.emit().
  • chat-message now wraps the raw text into an object with id, username, text, and timestamp fields before broadcasting.
  • disconnect removes the user from the Map and re-broadcasts the updated online list so other clients see them leave.
  • Save index.js.
  • Restart the server by running:
node index.js

You should see server running at http://localhost:3000 in the terminal.

Server won't start?

  • Check for a missing comma after timestamp: Date.now() or unmatched curly braces in the chat-message handler.
  • Make sure const onlineUsers = new Map(); sits above and outside the io.on("connection") block, not inside it.

Help me debug my Socket.IO server startup error.

Build the join screen

The client currently jumps straight into the chat. You need a gate: a join screen that collects a username before letting the user into the chatroom.

This requires a significant rewrite of App.jsx to add new state variables, additional event listeners, a handleJoin function, and a conditional render.

  • In chatroom-client/src/App.jsx, select all the existing code and delete it.
  • Replace it with the following code, starting with the imports and state declarations:
import { useEffect, useState, useRef } from "react";
import { io } from "socket.io-client";
import "./App.css";

const socket = io("http://localhost:3000");

function App() {
  // Track connection status, user identity, and chat data
  const [isConnected, setIsConnected] = useState(false);
  const [username, setUsername] = useState("");
  const [hasJoined, setHasJoined] = useState(false);
  const [message, setMessage] = useState("");
  const [messages, setMessages] = useState([]);
  const [onlineUsers, setOnlineUsers] = useState([]);
  const messagesEndRef = useRef(null);

What are these new state variables?

  • isConnected tracks the WebSocket connection status (shown on the join screen).
  • username holds the name the user picks on the join screen.
  • hasJoined controls which view renders: join screen (false) or chat interface (true).
  • onlineUsers stores the array of usernames currently in the room.
  • Directly below the state declarations, add the main useEffect that registers all four event listeners:
  useEffect(() => {
    function onConnect() {
      setIsConnected(true);
    }

    function onDisconnect() {
      setIsConnected(false);
    }

    function onChatMessage(msg) {
      setMessages((prev) => [...prev, msg]);
    }

    function onOnlineUsers(users) {
      setOnlineUsers(users);
    }

    socket.on("connect", onConnect);
    socket.on("disconnect", onDisconnect);
    socket.on("chat-message", onChatMessage);
    socket.on("online-users", onOnlineUsers);

    return () => {
      socket.off("connect", onConnect);
      socket.off("disconnect", onDisconnect);
      socket.off("chat-message", onChatMessage);
      socket.off("online-users", onOnlineUsers);
    };
  }, []);

Why register all listeners in one useEffect?

Grouping all socket.on() calls in a single effect with a matching cleanup (socket.off()) ensures the component never leaks listeners when it unmounts. The online-users listener is new. It updates the sidebar whenever someone joins or leaves.

  • Next, add the scroll effect and both handler functions directly below the useEffect:
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  // Emit chosen username to the server
  function handleJoin(e) {
    e.preventDefault();
    if (username.trim()) {
      socket.emit("set-username", username.trim());
      setHasJoined(true);
    }
  }

  // Send message text to the server
  function handleSend(e) {
    e.preventDefault();
    if (message.trim()) {
      socket.emit("chat-message", message.trim());
      setMessage("");
    }
  }

What does handleJoin do?

When the learner submits a username, handleJoin emits the set-username event to the server (which stores it in the Map) and flips hasJoined to true, which swaps the UI from the join screen to the chat.

  • Now add the join screen. This is a conditional early return that renders when hasJoined is false:
  if (!hasJoined) {
    return (
      <div className="join-screen">
        <h1>Join Chatroom</h1>
        <form onSubmit={handleJoin}>
          <input
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="Enter your username"
            autoFocus
          />
          <button type="submit">Join</button>
        </form>
        <p className="status">
          {isConnected ? "Connected" : "Connecting..."}
        </p>
      </div>
    );
  }

What does this render?

A centered form with a text input for the username, a Join button, and a small connection status indicator. Once the user submits, the component re-renders and skips this block entirely because hasJoined is now true.

You will add the chat interface return next (in the following substep), but first let's get the join screen rendering.

  • For now, add a temporary placeholder return and close the component after the join screen block:
  return <div>Chat goes here</div>;
}

export default App;
  • Save App.jsx.
  • Switch to the browser tab at http://localhost:5173.

You should see a centered "Join Chatroom" heading with a username input and a Join button. The status line below should read "Connected".

Seeing a blank page or errors?

  • Open the browser console (F12) and check for syntax errors. A missing closing parenthesis or brace is the most common cause.
  • Confirm your server is still running in the other terminal. The status will show "Connecting..." if the server is down.

Help me fix my join screen not rendering.

The join screen works. Now you need to replace that placeholder with the full chat interface.

Display online users and styled messages

The chat view needs two additions: a sidebar listing who is online, and message bubbles that show the sender's name. Your own messages should be visually distinct so you can tell them apart at a glance.

  • In App.jsx, find the temporary line return <div>Chat goes here</div>; and replace it with the chat interface return:
  return (
    <div className="chat-container">
      <aside className="sidebar">
        <h3>Online ({onlineUsers.length})</h3>
        <ul>
          {onlineUsers.map((user, i) => (
            <li key={i}>{user}</li>
          ))}
        </ul>
      </aside>
      <main className="chat-main">
        <div className="messages">
          {messages.map((msg) => (
            <div
              key={msg.id}
              className={`message ${msg.username === username ? "own" : ""}`}
            >
              <span className="message-username">{msg.username}</span>
              <span className="message-text">{msg.text}</span>
            </div>
          ))}
          <div ref={messagesEndRef} />
        </div>

What does this render?

The <aside> renders the Online Users sidebar by mapping over the onlineUsers array. The messages now render as objects with a msg.username label.

The ternary msg.username === username ? "own" : "" adds the own class to your own messages so CSS can style them differently.

  • Immediately below that code (still inside the <main> element), add the message form and close the component:
        <form className="message-form" onSubmit={handleSend}>
          <input
            type="text"
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            placeholder="Type a message..."
            autoFocus
          />
          <button type="submit">Send</button>
        </form>
      </main>
    </div>
  );
}

export default App;
  • Save App.jsx.
  • Check the browser. You should see the join screen. Type a name and click Join.

After joining you should see the chat interface with a sidebar reading "Online (1)" and your username listed. The layout will look unstyled. Time to fix that.

  • Open chatroom-client/src/App.css. Select all existing content and delete it.
  • Replace it with the following base reset and join-screen styles:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  height: 100vh;
}

#root {
  height: 100vh;
}

.join-screen {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100vh;
  gap: 1rem;
}

.join-screen h1 {
  font-size: 2rem;
}

.join-screen form {
  display: flex;
  gap: 0.5rem;
}

What do these styles do?

The reset removes default browser margins. The .join-screen rules use flexbox to center the join form vertically and horizontally on the page.

  • Continue adding to App.css directly below the code you just pasted:
.join-screen input {
  padding: 0.75rem 1rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 1rem;
  width: 250px;
}

.join-screen button {
  padding: 0.75rem 1.5rem;
  background: #0066ff;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 1rem;
  cursor: pointer;
}

.status {
  color: #666;
  font-size: 0.875rem;
}
  • Save App.css and check the browser (refresh if needed).

The join screen should now be cleanly centered with a styled input and a blue Join button.

  • Now add the sidebar and layout styles below the .status rule in App.css:
.chat-container {
  display: flex;
  height: 100vh;
}

.sidebar {
  width: 200px;
  background: #f5f5f5;
  padding: 1rem;
  border-right: 1px solid #ddd;
  overflow-y: auto;
}

.sidebar h3 {
  margin-bottom: 0.75rem;
  font-size: 0.875rem;
  text-transform: uppercase;
  color: #666;
}

.sidebar ul {
  list-style: none;
}

.sidebar li {
  padding: 0.375rem 0;
  font-size: 0.875rem;
}

.chat-main {
  flex: 1;
  display: flex;
  flex-direction: column;
}

What does this layout do?

The .chat-container uses flexbox to place the sidebar and main chat area side by side. The sidebar is a fixed 200px wide panel on the left.

  • Continue adding the message and message-form styles below:
.messages {
  flex: 1;
  overflow-y: auto;
  padding: 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.message {
  display: flex;
  flex-direction: column;
  max-width: 70%;
  padding: 0.5rem 0.75rem;
  background: #f0f0f0;
  border-radius: 8px;
  align-self: flex-start;
}

.message.own {
  background: #0066ff;
  color: white;
  align-self: flex-end;
}

.message-username {
  font-size: 0.75rem;
  font-weight: 600;
  margin-bottom: 0.25rem;
  opacity: 0.7;
}

How do own messages look different?

Messages default to left-aligned with a grey background. The .message.own override flips them to right-aligned with a blue background and white text, making it immediately obvious which messages are yours.

  • Add the final styles for the message text and input form:
.message-text {
  font-size: 0.9375rem;
}

.message-form {
  display: flex;
  padding: 1rem;
  gap: 0.5rem;
  border-top: 1px solid #ddd;
}

.message-form input {
  flex: 1;
  padding: 0.75rem 1rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 1rem;
}

.message-form button {
  padding: 0.75rem 1.5rem;
  background: #0066ff;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 1rem;
  cursor: pointer;
}
  • Save App.css.

Before you test, predict: if you open two browser tabs, join as "Alice" in one and "Bob" in the other, what do you expect the Online sidebar to show in each tab?

  • Open http://localhost:5173 in two separate browser tabs.
  • In the first tab, type Alice and click Join.
  • In the second tab, type Bob and click Join.

Both tabs now show "Alice" and "Bob" in the Online sidebar. Type a message in Alice's tab. It appears in both tabs with "Alice" as the sender name. Alice's own messages are right-aligned and blue. Bob sees them left-aligned and grey.

Messages not showing sender names?

  • Make sure you restarted the server after editing index.js. The old server still broadcasts raw text strings, not objects.
  • Check that App.jsx renders msg.username and msg.text (object properties), not msg directly (the old raw string pattern).
  • If the sidebar shows nothing, confirm the server has the set-username handler and that handleJoin emits set-username on the client.

Help me debug why messages or online users aren't working.

✔️ Awesome, I've got everything!

Great. Double check you have saved both App.jsx and App.css.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point. Scroll through and compare against yours.

chatroom-server/index.js:

import express from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";

const app = express();
const server = createServer(app);
const io = new Server(server, {
  cors: {
    origin: "http://localhost:5173",
  },
});

const onlineUsers = new Map();

io.on("connection", (socket) => {
  console.log("a user connected:", socket.id);

  socket.on("set-username", (username) => {
    onlineUsers.set(socket.id, username);
    io.emit("online-users", Array.from(onlineUsers.values()));
  });

  socket.on("chat-message", (text) => {
    const username = onlineUsers.get(socket.id) || "Anonymous";
    io.emit("chat-message", {
      id: socket.id + Date.now(),
      username,
      text,
      timestamp: Date.now(),
    });
  });
  socket.on("disconnect", () => {
    onlineUsers.delete(socket.id);
    io.emit("online-users", Array.from(onlineUsers.values()));
    console.log("user disconnected:", socket.id);
  });
});

server.listen(3000, () => {
  console.log("server running at http://localhost:3000");
});

chatroom-client/src/App.jsx:

import { useEffect, useState, useRef } from "react";
import { io } from "socket.io-client";
import "./App.css";

const socket = io("http://localhost:3000");

function App() {
  const [isConnected, setIsConnected] = useState(false);
  const [username, setUsername] = useState("");
  const [hasJoined, setHasJoined] = useState(false);
  const [message, setMessage] = useState("");
  const [messages, setMessages] = useState([]);
  const [onlineUsers, setOnlineUsers] = useState([]);
  const messagesEndRef = useRef(null);

  useEffect(() => {
    function onConnect() {
      setIsConnected(true);
    }

    function onDisconnect() {
      setIsConnected(false);
    }

    function onChatMessage(msg) {
      setMessages((prev) => [...prev, msg]);
    }

    function onOnlineUsers(users) {
      setOnlineUsers(users);
    }
    socket.on("connect", onConnect);
    socket.on("disconnect", onDisconnect);
    socket.on("chat-message", onChatMessage);
    socket.on("online-users", onOnlineUsers);

    return () => {
      socket.off("connect", onConnect);
      socket.off("disconnect", onDisconnect);
      socket.off("chat-message", onChatMessage);
      socket.off("online-users", onOnlineUsers);
    };
  }, []);

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  function handleJoin(e) {
    e.preventDefault();
    if (username.trim()) {
      socket.emit("set-username", username.trim());
      setHasJoined(true);
    }
  }

  function handleSend(e) {
    e.preventDefault();
    if (message.trim()) {
      socket.emit("chat-message", message.trim());
      setMessage("");
    }
  }
  if (!hasJoined) {
    return (
      <div className="join-screen">
        <h1>Join Chatroom</h1>
        <form onSubmit={handleJoin}>
          <input
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            placeholder="Enter your username"
            autoFocus
          />
          <button type="submit">Join</button>
        </form>
        <p className="status">
          {isConnected ? "Connected" : "Connecting..."}
        </p>
      </div>
    );
  }
  return (
    <div className="chat-container">
      <aside className="sidebar">
        <h3>Online ({onlineUsers.length})</h3>
        <ul>
          {onlineUsers.map((user, i) => (
            <li key={i}>{user}</li>
          ))}
        </ul>
      </aside>
      <main className="chat-main">
        <div className="messages">
          {messages.map((msg) => (
            <div
              key={msg.id}
              className={`message ${msg.username === username ? "own" : ""}`}
            >
              <span className="message-username">{msg.username}</span>
              <span className="message-text">{msg.text}</span>
            </div>
          ))}
          <div ref={messagesEndRef} />
        </div>
        <form className="message-form" onSubmit={handleSend}>
          <input
            type="text"
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            placeholder="Type a message..."
            autoFocus
          />
          <button type="submit">Send</button>
        </form>
      </main>
    </div>
  );
}

export default App;

chatroom-client/src/App.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  height: 100vh;
}

#root {
  height: 100vh;
}

.join-screen {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100vh;
  gap: 1rem;
}

.join-screen h1 {
  font-size: 2rem;
}

.join-screen form {
  display: flex;
  gap: 0.5rem;
}
.join-screen input {
  padding: 0.75rem 1rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 1rem;
  width: 250px;
}

.join-screen button {
  padding: 0.75rem 1.5rem;
  background: #0066ff;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 1rem;
  cursor: pointer;
}

.status {
  color: #666;
  font-size: 0.875rem;
}

.chat-container {
  display: flex;
  height: 100vh;
}

.sidebar {
  width: 200px;
  background: #f5f5f5;
  padding: 1rem;
  border-right: 1px solid #ddd;
  overflow-y: auto;
}
.sidebar h3 {
  margin-bottom: 0.75rem;
  font-size: 0.875rem;
  text-transform: uppercase;
  color: #666;
}

.sidebar ul {
  list-style: none;
}

.sidebar li {
  padding: 0.375rem 0;
  font-size: 0.875rem;
}

.chat-main {
  flex: 1;
  display: flex;
  flex-direction: column;
}

.messages {
  flex: 1;
  overflow-y: auto;
  padding: 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.message {
  display: flex;
  flex-direction: column;
  max-width: 70%;
  padding: 0.5rem 0.75rem;
  background: #f0f0f0;
  border-radius: 8px;
  align-self: flex-start;
}

.message.own {
  background: #0066ff;
  color: white;
  align-self: flex-end;
}

.message-username {
  font-size: 0.75rem;
  font-weight: 600;
  margin-bottom: 0.25rem;
  opacity: 0.7;
}

.message-text {
  font-size: 0.9375rem;
}

.message-form {
  display: flex;
  padding: 1rem;
  gap: 0.5rem;
  border-top: 1px solid #ddd;
}
.message-form input {
  flex: 1;
  padding: 0.75rem 1rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 1rem;
}

.message-form button {
  padding: 0.75rem 1.5rem;
  background: #0066ff;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 1rem;
  cursor: pointer;
}

Your chatroom now has identity and presence. Users pick a name, see who else is online, and every message is attributed. Next up: you will add live typing indicators so users can see when someone is composing a message.

Secret mission

Add Live Typing Indicators

Real chat apps show when someone is typing. Can you add a live "username is typing..." indicator that appears as someone types and disappears when they stop?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The chatroom-client folder on your Desktop (React app and its node_modules).
  • The chatroom-server folder on your Desktop (Node.js server and its node_modules).
  • The Vite dev server running on port 5173.
  • The Node.js server running on port 3000.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep chatting across tabs.

  • Your Vite dev server stays running at http://localhost:5173.
  • Your Socket.IO server stays running at http://localhost:3000.
  • Both project folders remain on your Desktop with all dependencies installed.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your project files stay intact so you can pick up where you left off.

  • Switch to the terminal running the Vite dev server and press Ctrl+C to stop it.
  • Switch to the terminal running the Node.js server and press Ctrl+C to stop it.
  • To resume later, open two terminals. In one, run cd ~/Desktop/chatroom-server && node index.js. In the other, run cd ~/Desktop/chatroom-client && npm run dev.

Delete - I don't want to use this again

Remove all project files and start fresh. This cannot be undone.

  • Stop both running servers by pressing Ctrl+C in each terminal.
  • Delete both project folders by running the following commands (macOS/Linux):
rm -rf ~/Desktop/chatroom-client
rm -rf ~/Desktop/chatroom-server

On Windows, run these commands instead:

rmdir /s /q %USERPROFILE%\Desktop\chatroom-client
rmdir /s /q %USERPROFILE%\Desktop\chatroom-server
  • Confirm the folders are gone by running ls ~/Desktop (macOS/Linux) or dir %USERPROFILE%\Desktop (Windows). Neither chatroom-client nor chatroom-server should appear.

Nice Work!

Nice Work!

You just built a fully functional real-time chatroom from scratch. Two browser tabs, two usernames, instant messages flying between them with zero page refreshes.

You've learned how to:

  • Build a Node.js WebSocket server with Express and Socket.IO that manages connections, broadcasts messages, and tracks online presence.
  • Wire a React frontend to a real-time backend using Socket.IO's event-driven API (emit, on, broadcast).
  • Implement user identity with a join screen, a live online users sidebar, and styled chat bubbles that visually distinguish your own messages from others.
  • Add live typing indicators using socket.broadcast.emit() and client-side debouncing (secret mission).

Ready to quiz yourself?