Build a Realtime Chat App
Wire WebSockets, PostgreSQL, and React into a room-based chat app.
Introduction
30 Second Summary
Think about how group chats work. You send a message and everyone in the conversation sees it appear instantly. That instant delivery relies on a persistent connection between your device and a server, not the usual request-response cycle of loading a web page.
In this project, you will build a room-based realtime chat application where messages appear instantly across multiple browser tabs. Your messages persist in a PostgreSQL database running in Docker, so chat history survives server restarts.
What You'll Build
A full-stack chat application with an Express backend using Socket.IO for realtime WebSocket communication, a React frontend, and PostgreSQL for message persistence.
By the end of this project, you'll have:
- A Docker Compose setup running PostgreSQL 18 with automatic schema initialization.
- An Express server with Socket.IO handling realtime events and a REST endpoint for fetching rooms.
- A React frontend with Socket.IO client that displays live messages and supports room switching.
- Secret Mission: Add online presence tracking so users can see who else is in their room.
Are there any prerequisites?
This project assumes basic Express.js and React experience, along with familiarity with SQL. You do not need prior experience with WebSockets or Docker.
Set Up the Project and Database
A chat app needs messages to persist and arrive instantly. Without a database, every message disappears when the server restarts. Without realtime delivery, users have to refresh to see new messages.
In this step, you will create the project folder structure, spin up a PostgreSQL database inside Docker, and initialize both the server and client projects with their dependencies.
In this step, get ready to:
- Verify Docker is running and create the project folder structure.
- Launch PostgreSQL 18 in a Docker container with tables and seed data.
- Initialize the Express server and React client with all dependencies.
Verify Docker and create the project folder
Docker Desktop runs containers on your machine. You will use it to host PostgreSQL without installing the database directly on your system.
- Switch back to your terminal and confirm Docker is accessible by running this command:
docker --version
- You should see a version number printed (for example, Docker version 28.x.x).
- Verify Docker Compose is available by running:
docker compose version
- You should see Docker Compose version v2.x.x in the output.
Command not found?
Make sure Docker Desktop is running. You should see the Docker whale icon in your system tray (Windows) or menu bar (macOS). If the icon is missing, open Docker Desktop from your applications.
help me troubleshoot Docker not being recognized in my terminal
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and the server subdirectory by running these commands:
mkdir realtime-chat
cd realtime-chat
mkdir server
- Confirm the folders exist by running:
ls
- You should see server in the output.
Create the Docker Compose file and database schema
Docker Compose lets you define and run multi-container setups with a single YAML file. Your compose file will run PostgreSQL 18 on port 5433 to avoid conflicts with your local PostgreSQL on port 5432.
- Open the realtime-chat folder in VS Code.
- Create a new file called docker-compose.yml in the project root and paste the following:
services:
db:
image: postgres:18
container_name: chat-db
environment:
POSTGRES_USER: chatuser
POSTGRES_PASSWORD: chatpass
POSTGRES_DB: chatdb
ports:
- "5433:5432"
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
- chat_data:/var/lib/postgresql
volumes:
chat_data:
What does this file do?
- image: postgres:18 pulls the official PostgreSQL 18 Docker image.
- container_name: chat-db gives the container a friendly name you can reference in commands.
- The environment block sets the database username, password, and database name.
- ports: "5433:5432" maps your machine's port 5433 to the container's default PostgreSQL port 5432.
- The first volume mounts init.sql into the container's init directory so it runs automatically on first start.
- The second volume (chat_data) persists database files across container restarts.
Why port 5433 instead of 5432?
Your machine already has PostgreSQL running locally on the default port 5432. Mapping Docker to 5433 lets both run side by side without conflicts.
- Save docker-compose.yml.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
services:
db:
image: postgres:18
container_name: chat-db
environment:
POSTGRES_USER: chatuser
POSTGRES_PASSWORD: chatpass
POSTGRES_DB: chatdb
ports:
- "5433:5432"
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
- chat_data:/var/lib/postgresql
volumes:
chat_data:
Next, you need the SQL script that creates the tables and inserts starter data.
- Create a new file called init.sql in the project root (same level as docker-compose.yml) and paste the following:
CREATE TABLE rooms (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE messages (
id SERIAL PRIMARY KEY,
room_id INTEGER REFERENCES rooms(id),
username VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO rooms (name) VALUES ('General'), ('Tech Talk'), ('Random');
What does this SQL do?
- The rooms table stores chat room names with unique constraints so no duplicates are possible.
- The messages table stores each message with a foreign key linking it to a room, a username, and a timestamp.
- The INSERT statement seeds three rooms so users have somewhere to chat immediately.
- Save init.sql.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
CREATE TABLE rooms (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE messages (
id SERIAL PRIMARY KEY,
room_id INTEGER REFERENCES rooms(id),
username VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO rooms (name) VALUES ('General'), ('Tech Talk'), ('Random');
Now start the database container.
- In your terminal, make sure you are in the realtime-chat folder, then start the container by running:
docker compose up -d
- Docker will pull the PostgreSQL 18 image (this may take a minute on first run) and start the container in the background.
- Verify the database is running and the seed data loaded by running:
docker exec -it chat-db psql -U chatuser -d chatdb -c "SELECT * FROM rooms;"
- You should see a table with three rows: General, Tech Talk, and Random.
Seeing a connection error?
- If the container is still starting, wait a few seconds and try the command again. PostgreSQL needs a moment to initialize on first launch.
- If you see a port conflict error, make sure nothing else is using port 5433 on your machine.
- If the rooms table does not exist, the init.sql file was not mounted correctly. Run docker compose down -v and then docker compose up -d to recreate the container from scratch.
help me troubleshoot my Docker PostgreSQL container
Initialize the server and client projects
Your chat app has two parts: an Express server handling WebSocket connections and database queries, and a React frontend displaying the chat interface.
- Initialize the server project by running:
cd server && npm init -y
- You should see npm print out the generated package.json contents.
- Open server/package.json in VS Code and replace its entire contents with the following:
{
"name": "chat-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"express": "4.19.2",
"socket.io": "4.8.3",
"pg": "8.21.0"
}
}
What is different from the default?
- "type": "module" enables ES module syntax (import/export) instead of CommonJS require().
- The dev script uses node --watch to auto-restart the server when you save changes.
- The three dependencies are express (web framework), socket.io (realtime WebSocket library), and pg (PostgreSQL client for Node.js).
- Save server/package.json.
- Install the dependencies by running:
npm install
- You should see npm download the packages and create a node_modules folder inside server/.
npm install failing?
Check that your package.json is valid JSON. A missing comma or extra trailing comma will cause a parse error. Compare your file against the code above.
help me fix my npm install error
✔️ Awesome, I've got everything!
Great. Make sure you saved the file and the install completed without errors.
ⓧ I'd like to double check the full code
{
"name": "chat-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"express": "4.19.2",
"socket.io": "4.8.3",
"pg": "8.21.0"
}
}
Now scaffold the React frontend using Vite.
- Move back to the project root and scaffold the client by running:
cd .. && npm create vite@latest client -- --template react
What does this command do?
npm create vite@latest runs the Vite scaffolding tool. client is the folder name for your frontend project. The --template react flag tells Vite to set up a React project without asking interactive questions.
- Move into the client folder and install the dependencies plus the Socket.IO client library by running:
cd client && npm install socket.io-client@4.8.3
- You should see npm install both the Vite template dependencies and socket.io-client.
- Confirm the project structure by moving back to the root and listing the folders:
cd ..
ls
- You should see client, server, docker-compose.yml, and init.sql in the output.
Vite scaffolding failed?
Make sure you are running Node.js v22.14.0 or higher. Vite requires Node.js 20.19+ or 22.12+. Run node --version to confirm.
help me fix the Vite scaffolding error
Your project skeleton is ready. You have a running database with seed data, a server folder with all dependencies installed, and a scaffolded React frontend. Next up, you will build the Express server and connect it to Socket.IO for realtime messaging.
Build the Express and Socket.IO Server
Your database is running in Docker and ready to accept connections. Now you need a server that can talk to that database and relay messages to connected clients in realtime.
In this step, you will build an Express server enhanced with Socket.IO for WebSocket support. You will also create a database connection module that reuses connections efficiently through a pool.
In this step, get ready to:
- Create a database connection module using a connection pool.
- Build an Express server with Socket.IO attached.
- Test a REST endpoint that queries your PostgreSQL rooms.
Create the database connection module
Before your server can query PostgreSQL, it needs a way to connect. A connection pool keeps a set of reusable database connections open so each query does not have to establish a brand new connection from scratch.
- Create a new file called db.js inside your server/ folder by running this command:
touch db.js
- Open db.js in VS Code and add the following code:
import pg from 'pg';
const { Pool } = pg;
// Create a connection pool pointing at the Docker PostgreSQL instance
const pool = new Pool({
user: 'chatuser',
password: 'chatpass',
host: 'localhost',
port: 5433,
database: 'chatdb',
});
export default pool;
What does this code do?
The pg package is a PostgreSQL client for Node.js. Because pg is a CommonJS package and your project uses ES modules, you import the default export and destructure Pool from it.
The pool connects to port 5433 because that is the port your Docker Compose file maps to the container's internal PostgreSQL port. Every other file in the server can now import this pool and run queries against your chat database.
- Save db.js.
- Confirm the file exists by running:
ls
You should see db.js alongside package.json and node_modules in the output.
Don't see db.js in the list?
Make sure your terminal is inside the server/ directory. Run pwd to check your current location.
If you are in the wrong directory, run cd ~/Desktop/realtime-chat/server to navigate back.
help me find my db.js file
✔️ Awesome, I've got everything!
Great. Make sure you have saved db.js before moving on.
ⓧ I'd like to double check the full code
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
user: 'chatuser',
password: 'chatpass',
host: 'localhost',
port: 5433,
database: 'chatdb',
});
export default pool;
Create the Express and Socket.IO server file
This file wires together Express for REST endpoints and Socket.IO for realtime WebSocket communication. Socket.IO needs to attach to an HTTP server rather than the Express app directly.
- Create a new file called index.js in your server/ folder by running:
touch index.js
- Open index.js in VS Code and add the server scaffold:
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import pool from './db.js';
// Create the Express app and wrap it in a raw HTTP server
const app = express();
const httpServer = createServer(app);
// Attach Socket.IO to the HTTP server with CORS for the Vite dev server
const io = new Server(httpServer, {
cors: {
origin: ['http://localhost:5173'],
},
});
Why wrap Express in an HTTP server?
Socket.IO needs access to the underlying HTTP server to upgrade connections from HTTP to WebSocket. Calling createServer(app) gives you a handle to that server so both Express routes and Socket.IO events share the same port.
The CORS configuration tells Socket.IO to accept connections from http://localhost:5173. That is the address where Vite serves your React frontend.
- Save index.js. You won't see output yet. The next chunk adds the route and starts the server.
Seeing a red underline on the import?
If your editor shows an error on import pool from './db.js', confirm that db.js is in the same server/ folder as index.js. The .js extension is required for ES module imports in Node.js.
help me fix my import error in index.js
- Below the Socket.IO initialization in index.js, add the REST endpoint and server startup:
app.get('/rooms', async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:5173');
try {
// Query all rooms from PostgreSQL
const result = await pool.query('SELECT * FROM rooms ORDER BY id');
res.json(result.rows);
} catch (err) {
console.error('Failed to fetch rooms:', err);
res.status(500).json({ error: 'Failed to fetch rooms' });
}
});
const PORT = 3001;
httpServer.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
The GET /rooms endpoint queries every room from your database and returns them as JSON. The manual Access-Control-Allow-Origin header allows your React frontend to call this endpoint without a CORS error.
The server listens on port 3001 using httpServer.listen() instead of app.listen(). This is important because Socket.IO is attached to httpServer. Using app.listen() would create a separate HTTP server that Socket.IO knows nothing about.
- Save index.js. The next substep will start the server and verify everything works.
Worried about the credentials in db.js?
The username and password (chatuser/chatpass) are hardcoded here for simplicity since this is a local-only development database. In a production app you would use environment variables instead.
help me understand best practices for database credentials
✔️ Awesome, I've got everything!
Great. Make sure you have saved index.js before moving on.
ⓧ I'd like to double check the full code
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import pool from './db.js';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: ['http://localhost:5173'],
},
});
app.get('/rooms', async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:5173');
try {
const result = await pool.query('SELECT * FROM rooms ORDER BY id');
res.json(result.rows);
} catch (err) {
console.error('Failed to fetch rooms:', err);
res.status(500).json({ error: 'Failed to fetch rooms' });
}
});
const PORT = 3001;
httpServer.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Run and test the server
Both files are saved. Time to start the server and confirm it can reach the database.
- Start the server with file watching enabled by running this command:
node --watch index.js
What does the --watch flag do?
The --watch flag tells Node.js to automatically restart the server whenever you change a file. This saves you from manually stopping and restarting after every edit.
You should see this output in your terminal:
Server running on http://localhost:3001
- Open your browser and navigate to http://localhost:3001/rooms.
You should see a JSON array containing your three seeded rooms from PostgreSQL: General, Tech Talk, and Random.
Seeing an error instead of rooms?
- If you see Cannot find module in the terminal, check that db.js and index.js are both in the server/ folder and that your import path includes the .js extension.
- If you see a connection refused error, make sure your Docker container is still running. Run docker ps in a new terminal tab to check for chat-db.
- If the browser shows an empty array [], the database tables may not have been seeded. This happens if the container was created before init.sql was added. Run docker compose down -v then docker compose up -d to recreate the database from scratch.
help me debug my Express server connection to PostgreSQL
Your server is running and serving rooms from the database. Next up, you will add realtime event handlers so the server can manage chat rooms and broadcast messages as they arrive.
Implement Realtime Chat Events
Your server is running and serving room data over REST. Now it's time to make it realtime.
HTTP requests follow a request-response pattern. The client asks, the server answers, and the connection closes. For chat, you need the server to push new messages the instant they arrive. Socket.IO events let you do exactly that. In this step, you'll wire up the event handlers that make joining rooms, sending messages, and receiving history all happen over a persistent WebSocket connection.
In this step, get ready to:
- Handle room joining and leaving with Socket.IO rooms.
- Save and broadcast messages in realtime using parameterized database queries.
- Log connection and disconnection events for debugging.
Add the connection handler with join and leave events
Every time a client connects, Socket.IO fires a connection event. Inside that callback, you register listeners for custom events like joining or leaving a room.
- In server/index.js, find the line that says const PORT = 3001; and add the following code above it:
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
socket.on('join-room', async (roomId) => {
// Add this socket to the Socket.IO room
socket.join(`room-${roomId}`);
try {
// Fetch the last 50 messages for this room
const result = await pool.query(
'SELECT * FROM messages WHERE room_id = $1 ORDER BY created_at ASC LIMIT 50',
[roomId]
);
// Send chat history back to the joining client
socket.emit('room-history', result.rows);
} catch (err) {
console.error('Failed to load room history:', err);
}
});
socket.on('leave-room', (roomId) => {
socket.leave(`room-${roomId}`);
});
});
What does this code do?
io.on('connection') fires whenever a new client establishes a WebSocket connection. The socket parameter represents that individual client.
socket.join() adds the client to a named room. You prefix with room- to keep room names unique. After joining, the server queries PostgreSQL for the last 50 messages and emits them back as a room-history event so the client can display chat history immediately.
socket.leave() removes the client from a room when they switch to a different one.
- Save server/index.js.
- Check your terminal. The server auto-restarts thanks to --watch and you should see no errors in the output.
Seeing a syntax error in the terminal?
Make sure the new io.on('connection', ...) block is placed between the closing }); of the /rooms endpoint and the const PORT = 3001; line. Check that all curly braces and parentheses are balanced.
help me fix a syntax error in my Socket.IO connection handler
Add the send-message event handler
Joining a room loads history. Now you need the event that actually saves new messages and pushes them to everyone in the room.
- In server/index.js, find the leave-room handler you just added. Add the following code below it, but still inside the io.on('connection') callback (before its closing });):
socket.on('send-message', async (data) => {
const { roomId, username, content } = data;
try {
// Insert the message and return the saved row
const result = await pool.query(
'INSERT INTO messages (room_id, username, content) VALUES ($1, $2, $3) RETURNING *',
[roomId, username, content]
);
// Broadcast the new message to everyone in the room
io.to(`room-${roomId}`).emit('new-message', result.rows[0]);
} catch (err) {
console.error('Failed to save message:', err);
}
});
What does this code do?
When the client emits a send-message event, the server destructures the incoming data to get roomId, username, and content.
The parameterized query ($1, $2, $3) prevents SQL injection. RETURNING * gives you the full saved row including the auto-generated id and created_at timestamp.
io.to(`room-${roomId}`).emit('new-message', ...) broadcasts to every socket in that room. This is what makes messages appear instantly for all connected users.
Why io.to() instead of socket.emit()?
socket.emit() sends only to the one client who triggered the event. io.to() sends to everyone in a room, including the sender. For chat, every participant needs to see every message, so broadcasting to the room is the correct pattern.
- Save server/index.js.
- Confirm the server restarts without errors in your terminal.
Server crashes with an unexpected token error?
The send-message handler must be inside the io.on('connection') callback. If you accidentally placed it after the closing }); of that block, socket will be undefined. Move it above the final }); of the connection handler.
help me fix my send-message handler placement
Add disconnect logging and verify the events
The last event to handle is disconnect. This fires automatically when a client closes the tab or loses connection.
- In server/index.js, add the following code below the send-message handler, still inside the io.on('connection') callback:
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
});
What does this code do?
Socket.IO fires the disconnect event when a client's connection drops. Logging the socket.id helps you debug which clients are connecting and disconnecting during development.
- Save server/index.js.
- Open a new browser tab and navigate to http://localhost:3001/rooms. Check your server terminal.
- You should see User connected: followed by a random 20-character ID. Close the tab and you should see User disconnected: with the same ID.
Why does visiting a REST endpoint trigger a connection log?
It won't trigger a Socket.IO connection by itself. The connection log only fires when a Socket.IO client connects (like the React app will in the next step). If you see the log when visiting /rooms in your browser, that means your browser's dev tools or an extension is attempting a WebSocket upgrade. The important thing is that your server starts without errors.
Not seeing any connection logs?
That's expected if you're only visiting the REST endpoint in a regular browser tab. The connection event only fires when a Socket.IO client connects. You'll see these logs in the next step when your React frontend connects.
For now, confirm the server is running without errors. You should still see Server running on http://localhost:3001 in your terminal output.
help me verify my Socket.IO event handlers are working
✔️ Awesome, I've got everything!
Great. Double check you've saved server/index.js and the server restarted without errors.
ⓧ I'd like to double check the full code
Here is the complete server/index.js at this point:
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import pool from './db.js';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: ['http://localhost:5173'],
},
});
app.get('/rooms', async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:5173');
try {
const result = await pool.query('SELECT * FROM rooms ORDER BY id');
res.json(result.rows);
} catch (err) {
console.error('Failed to fetch rooms:', err);
res.status(500).json({ error: 'Failed to fetch rooms' });
}
});
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
socket.on('join-room', async (roomId) => {
socket.join(`room-${roomId}`);
try {
const result = await pool.query(
'SELECT * FROM messages WHERE room_id = $1 ORDER BY created_at ASC LIMIT 50',
[roomId]
);
socket.emit('room-history', result.rows);
} catch (err) {
console.error('Failed to load room history:', err);
}
});
socket.on('leave-room', (roomId) => {
socket.leave(`room-${roomId}`);
});
socket.on('send-message', async (data) => {
const { roomId, username, content } = data;
try {
const result = await pool.query(
'INSERT INTO messages (room_id, username, content) VALUES ($1, $2, $3) RETURNING *',
[roomId, username, content]
);
io.to(`room-${roomId}`).emit('new-message', result.rows[0]);
} catch (err) {
console.error('Failed to save message:', err);
}
});
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
});
});
const PORT = 3001;
httpServer.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Your server now handles the complete message lifecycle: joining rooms, loading history, saving messages, and broadcasting them in realtime. Next up, you'll build the React frontend that connects to these events and displays the chat.
Build the React Chat Frontend
Your server is running and handling realtime events. Now you need a frontend that users actually interact with.
In this step, you will build a React chat interface that connects to your Socket.IO server. The frontend handles login, displays rooms, and sends messages that appear instantly for everyone in the same room.
In this step, get ready to:
- Create a Socket.IO client that connects to your Express server.
- Build the full chat component with login, rooms sidebar, and message area.
- Style and test realtime messaging across multiple browser tabs.
Create the Socket.IO client and app component
The frontend needs a persistent connection to the server to receive messages in realtime. The Socket.IO client library handles this connection, including automatic reconnection if the network drops.
- In VS Code, open the client/src folder inside your realtime-chat project.
- Create a new file called socket.js inside client/src/ and paste the following code:
import { io } from 'socket.io-client';
const socket = io('http://localhost:3001');
export default socket;
What does this code do?
This file creates a single Socket.IO client instance and exports it. By keeping it in its own file, every component that imports socket shares the same connection to the server.
The io('http://localhost:3001') call establishes a WebSocket connection to your Express server running on port 3001.
- Save socket.js.
Now you need the main chat component. The default App.jsx that Vite scaffolded has demo content that you will replace entirely.
- Open client/src/App.jsx in VS Code.
- Select all the existing content and delete it.
- Replace it with the following code:
import { useState, useEffect, useRef } from 'react';
import socket from './socket';
import './App.css';
function App() {
const [username, setUsername] = useState('');
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [rooms, setRooms] = useState([]);
const [currentRoom, setCurrentRoom] = useState(null);
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const messagesEndRef = useRef(null);
useEffect(() => {
fetch('http://localhost:3001/rooms')
.then((res) => res.json())
.then((data) => setRooms(data))
.catch((err) => console.error('Failed to fetch rooms:', err));
}, []);
useEffect(() => {
socket.on('room-history', (history) => {
setMessages(history);
});
socket.on('new-message', (message) => {
setMessages((prev) => [...prev, message]);
});
return () => {
socket.off('room-history');
socket.off('new-message');
};
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleLogin = (e) => {
e.preventDefault();
if (username.trim()) {
setIsLoggedIn(true);
}
};
const joinRoom = (room) => {
if (currentRoom) {
socket.emit('leave-room', currentRoom.id);
}
setCurrentRoom(room);
setMessages([]);
socket.emit('join-room', room.id);
};
const sendMessage = (e) => {
e.preventDefault();
if (newMessage.trim() && currentRoom) {
socket.emit('send-message', {
roomId: currentRoom.id,
username,
content: newMessage,
});
setNewMessage('');
}
};
if (!isLoggedIn) {
return (
<div className="login-container">
<h1>Realtime Chat</h1>
<form onSubmit={handleLogin}>
<input
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<button type="submit">Join Chat</button>
</form>
</div>
);
}
return (
<div className="chat-container">
<aside className="sidebar">
<h2>Rooms</h2>
<ul>
{rooms.map((room) => (
<li
key={room.id}
className={currentRoom?.id === room.id ? 'active' : ''}
onClick={() => joinRoom(room)}
>
{room.name}
</li>
))}
</ul>
</aside>
<main className="chat-area">
{currentRoom ? (
<>
<header className="chat-header">
<h2>{currentRoom.name}</h2>
</header>
<div className="messages">
{messages.map((msg) => (
<div
key={msg.id}
className={`message ${msg.username === username ? 'own' : ''}`}
>
<span className="message-username">{msg.username}</span>
<p className="message-content">{msg.content}</p>
</div>
))}
<div ref={messagesEndRef} />
</div>
<form className="message-form" onSubmit={sendMessage}>
<input
type="text"
placeholder="Type a message..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
/>
<button type="submit">Send</button>
</form>
</>
) : (
<div className="no-room">
<p>Select a room to start chatting</p>
</div>
)}
</main>
</div>
);
}
export default App;
What does this component do?
This single component manages the entire chat experience. It has two views controlled by isLoggedIn state: a login form and the main chat interface.
- The login view collects a username and sets isLoggedIn to true on submit.
- The chat view renders a sidebar listing rooms from your REST endpoint and a main area showing messages for the selected room.
- State variables track the current room, message list, and new message input so React re-renders automatically when data changes.
- Save App.jsx.
You will not see a visual change yet because the app needs styling. The next substep explains the event wiring, and the final substep adds CSS and starts the dev server.
Seeing a red underline on the socket import?
Make sure you created socket.js in the same client/src/ directory as App.jsx. The import path './socket' is relative to the current file.
help me fix a Socket.IO client import error in my React project
Walk through the realtime event flow
The App component you just wrote contains three patterns that make the chat realtime. Understanding these patterns is key to working with Socket.IO in any project.
How do the socket listeners work?
The second useEffect registers two event listeners on the socket connection:
- room-history fires when you join a room. The server queries the last 50 messages from PostgreSQL and sends them back. The listener replaces the messages array with this history.
- new-message fires whenever anyone in the room sends a message. The listener appends the new message to the existing array using a spread pattern.
The cleanup function (socket.off) removes the listeners when the component unmounts. This prevents memory leaks from stale listeners.
How do the emit functions work?
The joinRoom function emits leave-room for the previous room (if any), then emits join-room with the new room's ID. The server receives this, adds the socket to the room, and sends back the message history.
The sendMessage function emits send-message with the room ID, username, and message content. The server saves it to PostgreSQL and broadcasts it to everyone in the room.
What is the auto-scroll ref?
The messagesEndRef is a React ref attached to an empty div at the bottom of the messages list. Every time the messages array changes, the third useEffect scrolls that div into view. This keeps the newest message visible without manual scrolling.
Style and test the chat app
The component logic is complete. Now you need CSS to transform the raw HTML into a usable chat interface, and then you can test realtime messaging.
- Open client/src/App.css in VS Code.
- Select all the existing Vite demo styles and delete them.
- Replace the contents with the following CSS:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
height: 100vh;
}
#root {
height: 100vh;
}
.login-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 1.5rem;
}
.login-container h1 {
font-size: 2.5rem;
color: #00d4ff;
}
.login-container form {
display: flex;
gap: 0.5rem;
}
.login-container input {
padding: 0.75rem 1rem;
border: 1px solid #333;
border-radius: 8px;
background: #16213e;
color: #eee;
font-size: 1rem;
width: 250px;
}
.login-container button {
padding: 0.75rem 1.5rem;
background: #00d4ff;
color: #1a1a2e;
border: none;
border-radius: 8px;
font-weight: bold;
cursor: pointer;
}
.chat-container {
display: flex;
height: 100vh;
}
.sidebar {
width: 250px;
background: #16213e;
padding: 1.5rem;
border-right: 1px solid #333;
}
.sidebar h2 {
margin-bottom: 1rem;
color: #00d4ff;
}
.sidebar ul {
list-style: none;
}
.sidebar li {
padding: 0.75rem 1rem;
border-radius: 8px;
cursor: pointer;
margin-bottom: 0.25rem;
}
.sidebar li:hover {
background: #1a1a2e;
}
.sidebar li.active {
background: #0f3460;
color: #00d4ff;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-header {
padding: 1rem 1.5rem;
background: #16213e;
border-bottom: 1px solid #333;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.message {
max-width: 70%;
padding: 0.75rem 1rem;
background: #16213e;
border-radius: 12px;
align-self: flex-start;
}
.message.own {
background: #0f3460;
align-self: flex-end;
}
.message-username {
font-size: 0.75rem;
color: #00d4ff;
display: block;
margin-bottom: 0.25rem;
}
.message-content {
font-size: 0.95rem;
line-height: 1.4;
}
.message-form {
display: flex;
gap: 0.5rem;
padding: 1rem 1.5rem;
background: #16213e;
border-top: 1px solid #333;
}
.message-form input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid #333;
border-radius: 8px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
}
.message-form button {
padding: 0.75rem 1.5rem;
background: #00d4ff;
color: #1a1a2e;
border: none;
border-radius: 8px;
font-weight: bold;
cursor: pointer;
}
.no-room {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
font-size: 1.25rem;
}
What does this CSS do?
The stylesheet creates a dark-themed chat layout with a fixed sidebar and flexible chat area. Key design decisions:
- The .chat-container uses flexbox to place the sidebar and chat area side by side at full viewport height.
- The .message.own class aligns your own messages to the right with a different background color so you can tell them apart.
- The .messages container uses overflow-y: auto so it scrolls independently when messages overflow.
- Save App.css.
- Open a new terminal in VS Code by pressing Ctrl+` (backtick key).
- Navigate to the client folder and start the dev server by running:
cd client
npm run dev
- Open two browser tabs and navigate both to http://localhost:5173.
- In the first tab, type a username (e.g. Alice) and click Join Chat.
- In the second tab, type a different username (e.g. Bob) and click Join Chat.
- In both tabs, click the General room in the sidebar.
- Type a message in one tab and press Enter or click Send.
You should see the message appear instantly in both browser tabs. Your own messages appear on the right with a darker background. The other user's messages appear on the left.
- Refresh one of the tabs.
- Click the General room again. You should see the message history load from PostgreSQL, proving persistence works across page refreshes.
Messages not appearing in both tabs?
Check that your server is still running in the other terminal (you should see Server running on http://localhost:3001 in the output). If it crashed, restart it with node --watch index.js from inside the server/ folder.
Also confirm both tabs joined the same room. Messages only broadcast to sockets in the same Socket.IO room.
help me debug why my Socket.IO messages are not appearing in both browser tabs
✔️ Awesome, I've got everything!
Great. Double check that all three files are saved in your client/src/ folder.
ⓧ I'd like to double check the full code
Here is the complete code for all three client files:
import { io } from 'socket.io-client';
const socket = io('http://localhost:3001');
export default socket;
import { useState, useEffect, useRef } from 'react';
import socket from './socket';
import './App.css';
function App() {
const [username, setUsername] = useState('');
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [rooms, setRooms] = useState([]);
const [currentRoom, setCurrentRoom] = useState(null);
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const messagesEndRef = useRef(null);
useEffect(() => {
fetch('http://localhost:3001/rooms')
.then((res) => res.json())
.then((data) => setRooms(data))
.catch((err) => console.error('Failed to fetch rooms:', err));
}, []);
useEffect(() => {
socket.on('room-history', (history) => {
setMessages(history);
});
socket.on('new-message', (message) => {
setMessages((prev) => [...prev, message]);
});
return () => {
socket.off('room-history');
socket.off('new-message');
};
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleLogin = (e) => {
e.preventDefault();
if (username.trim()) {
setIsLoggedIn(true);
}
};
const joinRoom = (room) => {
if (currentRoom) {
socket.emit('leave-room', currentRoom.id);
}
setCurrentRoom(room);
setMessages([]);
socket.emit('join-room', room.id);
};
const sendMessage = (e) => {
e.preventDefault();
if (newMessage.trim() && currentRoom) {
socket.emit('send-message', {
roomId: currentRoom.id,
username,
content: newMessage,
});
setNewMessage('');
}
};
if (!isLoggedIn) {
return (
<div className="login-container">
<h1>Realtime Chat</h1>
<form onSubmit={handleLogin}>
<input
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<button type="submit">Join Chat</button>
</form>
</div>
);
}
return (
<div className="chat-container">
<aside className="sidebar">
<h2>Rooms</h2>
<ul>
{rooms.map((room) => (
<li
key={room.id}
className={currentRoom?.id === room.id ? 'active' : ''}
onClick={() => joinRoom(room)}
>
{room.name}
</li>
))}
</ul>
</aside>
<main className="chat-area">
{currentRoom ? (
<>
<header className="chat-header">
<h2>{currentRoom.name}</h2>
</header>
<div className="messages">
{messages.map((msg) => (
<div
key={msg.id}
className={`message ${msg.username === username ? 'own' : ''}`}
>
<span className="message-username">{msg.username}</span>
<p className="message-content">{msg.content}</p>
</div>
))}
<div ref={messagesEndRef} />
</div>
<form className="message-form" onSubmit={sendMessage}>
<input
type="text"
placeholder="Type a message..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
/>
<button type="submit">Send</button>
</form>
</>
) : (
<div className="no-room">
<p>Select a room to start chatting</p>
</div>
)}
</main>
</div>
);
}
export default App;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
height: 100vh;
}
#root {
height: 100vh;
}
.login-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 1.5rem;
}
.login-container h1 {
font-size: 2.5rem;
color: #00d4ff;
}
.login-container form {
display: flex;
gap: 0.5rem;
}
.login-container input {
padding: 0.75rem 1rem;
border: 1px solid #333;
border-radius: 8px;
background: #16213e;
color: #eee;
font-size: 1rem;
width: 250px;
}
.login-container button {
padding: 0.75rem 1.5rem;
background: #00d4ff;
color: #1a1a2e;
border: none;
border-radius: 8px;
font-weight: bold;
cursor: pointer;
}
.chat-container {
display: flex;
height: 100vh;
}
.sidebar {
width: 250px;
background: #16213e;
padding: 1.5rem;
border-right: 1px solid #333;
}
.sidebar h2 {
margin-bottom: 1rem;
color: #00d4ff;
}
.sidebar ul {
list-style: none;
}
.sidebar li {
padding: 0.75rem 1rem;
border-radius: 8px;
cursor: pointer;
margin-bottom: 0.25rem;
}
.sidebar li:hover {
background: #1a1a2e;
}
.sidebar li.active {
background: #0f3460;
color: #00d4ff;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-header {
padding: 1rem 1.5rem;
background: #16213e;
border-bottom: 1px solid #333;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.message {
max-width: 70%;
padding: 0.75rem 1rem;
background: #16213e;
border-radius: 12px;
align-self: flex-start;
}
.message.own {
background: #0f3460;
align-self: flex-end;
}
.message-username {
font-size: 0.75rem;
color: #00d4ff;
display: block;
margin-bottom: 0.25rem;
}
.message-content {
font-size: 0.95rem;
line-height: 1.4;
}
.message-form {
display: flex;
gap: 0.5rem;
padding: 1rem 1.5rem;
background: #16213e;
border-top: 1px solid #333;
}
.message-form input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid #333;
border-radius: 8px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
}
.message-form button {
padding: 0.75rem 1.5rem;
background: #00d4ff;
color: #1a1a2e;
border: none;
border-radius: 8px;
font-weight: bold;
cursor: pointer;
}
.no-room {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
font-size: 1.25rem;
}
Your realtime chat is fully functional. Two users can join the same room, exchange messages instantly, and see their history survive a page refresh. Next up, you will take this further with a secret mission that adds online presence tracking.
Secret mission
Add Online Presence Tracking
Your chat works great, but users have no idea who else is in the room with them. Can you add a live "Online" panel that updates as people join and leave?
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:
- Docker container chat-db running PostgreSQL 18 (no cost, local only).
- Named Docker volume realtime-chat_chat_data persisting database files.
- Project folder realtime-chat on your Desktop containing server and client code.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your chat app.
- Your Docker container continues running in the background and restarts automatically if you reboot.
- Your project files remain on your Desktop, ready for further development.
- You can start the server and client again at any time with node --watch index.js and npm run dev.
Pause - I'll come back to this later
Shut down the Docker container to free up system resources while keeping your data intact for later.
- Open your terminal and navigate to the realtime-chat folder on your Desktop.
- Stop the Docker container by running:
docker compose stop
- Your database data is preserved in the named volume. When you're ready to resume, run docker compose up -d from the same folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your chat history and all project files permanently.
- Open your terminal and navigate to the realtime-chat folder on your Desktop.
- Remove the Docker container and its named volume by running:
docker compose down -v
- Navigate back to your Desktop and delete the project folder:
cd ~/Desktop
rm -rf realtime-chat
- On Windows, use this command instead:
cd ~\Desktop
Remove-Item -Recurse -Force realtime-chat
- Confirm the container is gone by running docker ps -a. You should no longer see chat-db in the list.
Nice Work!
Nice Work!
You just built a fully functional realtime chat application from scratch. Messages fly between browser tabs instantly, persist in a database, and survive server restarts.
You've learned how to:
- Built a realtime chat server using Express and Socket.IO with room-based messaging, event handling, and database persistence.
- Containerized PostgreSQL with Docker Compose, including automatic schema initialization and data volumes.
- Created a React frontend with a Socket.IO client that receives and displays messages in realtime across multiple tabs.
- Secret Mission: Added online presence tracking with server-side user state management using a Map data structure.
Ready to quiz yourself?