Building a Real-Time Chat Room Application with Express, TypeScript, Socket.IO, Next.js, and TailwindCSS
In this tutorial, we’ll build a real-time chat room application. We’ll break it down into two main sections:
- Backend: Using Express, TypeScript, Socket.IO, and MongoDB.
- Frontend: Using Next.js and TailwindCSS.
Part 1: Backend (Express, TypeScript, Socket.IO, MongoDB)
1.1 Setting Up the Backend
To start, let’s initialize a new Node.js project for our backend. We’ll use Express for building our API, TypeScript for type safety, Socket.IO for real-time communication, and MongoDB for data storage.
Step 1: Initialize Project
Run the following commands to initialize the project and install the necessary dependencies:
mkdir chat-app-backend
cd chat-app-backend
npm init -y
npm install express socket.io mongoose dotenv cors
npm install typescript ts-node-dev @types/express @types/socket.io @types/node --save-dev
then, in the package.json file, update the script:
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only api/index.ts",
"build": "tsc",
"start": "node dist/index.js",
},
Next, set up TypeScript by running:
npx tsc --init
Now, update your tsconfig.json
to ensure it's correctly configured for your project:
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./api"
},
"include": ["api/**/*", "api/index.ts"],
"exclude": ["node_modules"]
}
Step 2: Project Structure
Create the following folder structure for the backend:
chat-app-backend/
├── api/
│ ├── configs/
│ ├── controllers/
│ ├── exceptions/
│ ├── models/
│ ├── routes/
│ ├── services/
│ ├── app.ts
│ └── index.ts
├── .env
├── package.json
└── tsconfig.json
Step 3: MongoDB Configuration
In the configs/
folder, create a mongo.config.ts
file to handle MongoDB connections:
import mongoose from 'mongoose';
const connectMongo = async () => {
try {
await mongoose.connect(process.env.MONGO_URI as string);
console.log('MongoDB connected');
} catch (error) {
console.error('MongoDB connection error:', error);
}
};
export default connectMongo;
In your .env
file, add the MongoDB connection string:
PORT=5000
MONGO_URI=mongodb://localhost:27017/chat-app
Step 4: Creating User and Message Models
Next, we will define our User and Message models using Mongoose.
user.model.ts
:
import mongoose, { Schema, Document } from 'mongoose';
export interface IUser extends Document {
name: string;
socketId: string;
createdAt: Date;
}
const UserSchema: Schema = new Schema({
name: { type: String, required: true },
socketId: { type: String, required: true },
createdAt: { type: Date, default: Date.now },
});
export const User = mongoose.model<IUser>('User', UserSchema);
message.model.ts
:
import mongoose, { Schema, Document } from 'mongoose';
export interface IMessage extends Document {
senderId: string;
text: string;
createdAt: Date;
}
const MessageSchema: Schema = new Schema({
roomId: { type: String, required: true },
senderId: { type: String, required: true },
username: { type: String, required: true },
text: { type: String, required: true },
createdAt: { type: Date, default: Date.now },
});
export const Message = mongoose.model<IMessage>('Message', MessageSchema);
room.model.ts
:
import mongoose, { Schema, Document } from 'mongoose';
export interface IRoom extends Document {
name: string;
participants: string[];
}
const RoomSchema = new Schema<IRoom>({
name: { type: String, required: true, unique: true },
participants: [{ type: String, required: true }],
});
export const Room = mongoose.model<IRoom>('Room', RoomSchema);
Step 5: Service Layer for User and Message
Now, let’s implement the service logic in services/
that will interact with our models.
user.service.ts
:
import { IUser, User } from '../models/user.model';
export const createOrUpdateUser = async (
name: string,
socketId: string,
): Promise<IUser> => {
const existingUser = await User.findOne({ name });
if (existingUser) {
existingUser.socketId = socketId;
return await existingUser.save();
}
const newUser = new User({ name, socketId });
return await newUser.save();
};
export const deleteUserBySocketId = async (socketId: string): Promise<void> => {
await User.deleteOne({ socketId });
};
message.service.ts
:
import { Message, IMessage } from '../models/message.model';
export const createMessage = async (
roomId: string,
senderId: string,
username: string,
text: string,
): Promise<IMessage> => {
const message = new Message({ roomId, senderId, username, text });
return await message.save();
};
export const getMessagesByRoomId = async (
roomId: string,
): Promise<IMessage[]> => {
return await Message.find({ roomId });
};
room.service.ts
:
import { Room } from '../models/room.model';
export const createRoom = async (roomName: string) => {
const room = new Room({ name: roomName });
await room.save();
return room;
};
export const getAllRooms = async () => {
return await Room.find();
};
export const addUserToRoom = async (roomId: string, userId: string) => {
const room = await Room.findById(roomId);
if (room) {
room.participants.push(userId);
await room.save();
}
};
Step 6: Socket.IO Controller
In the controllers/
folder, create socket.controller.ts
to handle all socket events.
socket.controller.ts
:
import { Server, Socket } from 'socket.io';
import * as userService from '../services/user.service';
import * as messageService from '../services/message.service';
export const handleSocketConnection = (io: Server) => {
io.on('connection', (socket: Socket) => {
console.log(`User connected: ${socket.id}`);
socket.on('join', async ({ name, roomId }) => {
await userService.createOrUpdateUser(name, socket.id);
socket.join(roomId);
console.log(`${name} joined room: ${roomId}`);
});
socket.on('message', async ({ roomId, senderId, username, text }) => {
const message = await messageService.createMessage(
roomId,
senderId,
username,
text,
);
io.to(roomId).emit('message', message);
});
socket.on('disconnect', async () => {
await userService.deleteUserBySocketId(socket.id);
console.log(`User disconnected: ${socket.id}`);
});
});
};
Step 7: Room Controller
In the controllers/
folder, create room.controller.ts
to handle all room chat features.
room.controller.ts
:
import { Request, Response } from 'express';
import * as userService from '../services/user.service';
import * as messageService from '../services/message.service';
import * as roomService from '../services/room.service';
export const joinChat = async (req: Request, res: Response): Promise<void> => {
try {
const { name, socketId, roomId } = req.body;
if (!name || !socketId || !roomId) {
res
.status(400)
.json({ message: 'Name, socketId, and roomId are required' });
return;
}
const user = await userService.createOrUpdateUser(name, socketId);
await roomService.addUserToRoom(roomId, user._id as string);
res.status(201).json({ message: 'User joined', user });
} catch (error) {
console.error('Error in joinChat:', error);
res.status(500).json({ message: 'Internal server error' });
}
};
export const sendMessage = async (
req: Request,
res: Response,
): Promise<void> => {
try {
const { senderId, roomId, username, text } = req.body;
if (!senderId || !roomId || !username || !text) {
res.status(400).json({ message: 'All fields are required' });
return;
}
const message = await messageService.createMessage(
roomId,
senderId,
username,
text,
);
res.status(201).json({ message: 'Message sent', data: message });
} catch (error) {
console.error('Error in sendMessage:', error);
res.status(500).json({ message: 'Internal server error' });
}
};
export const getRoomMessages = async (
req: Request,
res: Response,
): Promise<void> => {
try {
const { roomId } = req.params;
const messages = await messageService.getMessagesByRoomId(roomId);
res.status(200).json(messages);
} catch (error) {
console.error('Error in getRoomMessages:', error);
res.status(500).json({ message: 'Internal server error' });
}
};
export const getRooms = async (req: Request, res: Response): Promise<void> => {
try {
const rooms = await roomService.getAllRooms();
res.status(200).json(rooms);
} catch (error) {
console.error('Error in getRooms:', error);
res.status(500).json({ message: 'Internal server error' });
}
};
export const createRoom = async (
req: Request,
res: Response,
): Promise<void> => {
try {
const { roomName } = req.body;
if (!roomName) {
res.status(400).json({ message: 'All fields are required' });
return;
}
const message = await roomService.createRoom(roomName);
res.status(201).json({ message: 'Message sent', data: message });
} catch (error) {
console.error('Error in createRoom:', error);
res.status(500).json({ message: 'Internal server error' });
}
};
Step 8: Set Up Exception
In the exceptions/
folder, create httpException.ts
to handle exception from http.
httpException.ts
:
export class HttpException extends Error {
status: number;
message: string;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.message = message;
}
}
Step 9: Setting up Routing
In routes/chat.routes.ts
, set up the Express routing configuration:
import { Router } from 'express';
import * as roomController from '../controllers/room.controller';
const router = Router();
router.post('/join', roomController.joinChat);
router.post('/message', roomController.sendMessage);
router.get('/messages/:roomId', roomController.getRoomMessages);
router.post('/rooms', roomController.createRoom);
router.get('/rooms', roomController.getRooms);
export default router;
Step 10: Setting up Express App
In app.ts
, set up the Express server configuration:
app.ts
:
import express, { Application, NextFunction, Request, Response } from 'express';
import { createServer } from 'http';
import dotenv from 'dotenv';
import cors from 'cors';
import { Server as SocketIOServer } from 'socket.io';
import connectMongo from './configs/mongo.config';
import chatRoutes from './routes/chat.routes';
import { handleSocketConnection } from './controllers/socket.controller';
connectMongo();
dotenv.config();
const app: Application = express();
const httpServer = createServer(app);
const io = new SocketIOServer(httpServer, {
cors: {
origin: 'http://localhost:3000',
methods: ['GET', 'POST'],
credentials: true,
},
});
app.use(express.json());
app.use(
cors({
origin: 'http://localhost:3000',
methods: ['GET', 'POST'],
credentials: true,
}),
);
handleSocketConnection(io);
app.use('/api/chat', chatRoutes);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});
export { httpServer };
Step 11: Creating the Main Server (index.ts)
In index.ts
, integrate Socket.IO with HTTP and start the server:
index.ts
:
import 'dotenv/config';
import { httpServer } from './app';
const PORT = process.env.PORT || 5000;
httpServer.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
1.2 Running the Backend
Now that everything is set up, run the backend:
npm run dev
Your backend is now running with real-time communication enabled via Socket.IO.
Part 2: Frontend (Next.js, TailwindCSS, Socket.IO Client)
2.1 Setting Up the Frontend
Step 1: Initialize Next.js Project
Next, create the frontend with Next.js and TypeScript:
npx create-next-app@latest chat-app-frontend --typescript
cd chat-app-frontend
npm install socket.io-client axios moment sonner react-icons
Step 2: Create API and Socket Clients
In the src/global/
folder, create two files: apiClient.ts
and socketClient.ts
.
apiClient.ts
:
import axios from "axios";
const apiClient = axios.create({
baseURL: "http://localhost:5000/api/chat",
withCredentials: true,
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
export default apiClient;
socketClient.ts
:
import { io, Socket } from "socket.io-client";
const socketClient: Socket = io("http://localhost:5000", {
withCredentials: true,
});
export default socketClient;
2.2 Creating the Chat Page
Now, let’s create the chat interface in app/page.tsx
.
app/page.tsx
:
"use client";
import apiClient from "@/global/apiClient";
import socketClient from "@/global/socketClient";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
interface Room {
id: string;
name: string;
}
const LoginPage: React.FC = () => {
const [name, setName] = useState("");
const [rooms, setRooms] = useState<Room[]>([]);
const [selectedRoomId, setSelectedRoomId] = useState("");
const history = useRouter();
const fetchRooms = async () => {
try {
const response = await apiClient.get("/rooms");
if (!response.data) {
throw new Error("Failed to fetch rooms");
}
setRooms(response.data);
} catch (error) {
console.error("Error fetching rooms:", error);
}
};
useEffect(() => {
fetchRooms();
}, []);
const handleJoinRoom = () => {
if (name && selectedRoomId) {
socketClient.emit("join", { name, roomId: selectedRoomId });
localStorage.setItem("username", name);
history.push(`/chats/${selectedRoomId}`);
}
};
return (
<div className="flex flex-col items-center justify-center h-screen bg-gray-800 text-white">
<h1 className="text-4xl mb-4">Join Chat Room</h1>
<input
type="text"
placeholder="Enter your name"
className="mb-4 px-6 py-4 rounded bg-gray-700 text-white"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<select
key={selectedRoomId}
className="mb-4 px-16 py-4 rounded bg-gray-700 text-white"
value={selectedRoomId}
onChange={(e) => setSelectedRoomId(e.target.value)}
>
<option value="" disabled>
Select Room
</option>
{rooms.map((room) => (
<option key={room.id} value={room.id}>
{room.name}
</option>
))}
</select>
<button
onClick={handleJoinRoom}
className="bg-blue-600 hover:bg-blue-500 text-white font-bold py-2 px-4 rounded"
>
Join Room
</button>
</div>
);
};
export default LoginPage;
then in layout.tsx we add <Toaster />.
app/layout.tsx
:
import type { Metadata } from "next";
import "./globals.css";
import { Toaster } from "sonner";
export const metadata: Metadata = {
title: "Chat App",
description: "Chat App By Adi Munawar",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
{children}
<Toaster />
</body>
</html>
);
}
2.3 Creating the Chat Page
Now, let’s create the chat interface in app/chats
.
chats/[roomId]/page.tsx
:
"use client";
import Message from "@/components/Message";
import MessageInput from "@/components/MessageInput";
import TopBar from "@/components/Topbar";
import apiClient from "@/global/apiClient";
import socketClient from "@/global/socketClient";
import { useParams, useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { toast } from "sonner";
const ChatRoom: React.FC = () => {
const router = useRouter();
const { roomId } = useParams<{ roomId: string }>();
const [messages, setMessages] = useState<any[]>([]);
useEffect(() => {
const fetchMessages = async () => {
try {
const response = await apiClient.get(`/messages/${roomId}`);
setMessages(response.data);
} catch (error) {
console.error("Failed to fetch messages:", error);
toast.error("Failed to fetch messages");
}
};
fetchMessages();
}, [roomId]);
useEffect(() => {
socketClient.emit("join", { name: "User", roomId });
socketClient.on("message", (message) => {
setMessages((prevMessages) => [...prevMessages, message]);
});
return () => {
socketClient.off("message");
};
}, [roomId]);
const handleLogout = () => {
router.push("/");
toast.success("User logged out");
};
return (
<div className="flex flex-col h-screen bg-gray-800 text-white">
<TopBar roomId={roomId} onLogout={handleLogout} />
<div className="flex-1 overflow-auto p-4">
{messages.map((msg, index) => (
<Message key={index} message={msg} />
))}
</div>
<MessageInput roomId={roomId} />
</div>
);
};
export default ChatRoom;
components/Message.tsx
:
import React from "react";
import Image from "next/image";
import moment from "moment";
const Message: React.FC<{ message: any }> = ({ message }) => {
const avatarUrl = `https://ui-avatars.com/api/?name=${message.username.toUpperCase()}&background=random`;
return (
<div className="flex items-start mb-6">
<div className="flex-shrink-0 mr-4">
<Image
src={avatarUrl}
alt={message.sender}
width={45}
height={45}
className="rounded-full"
/>
</div>
{/* Message content */}
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<span className="font-semibold text-white">{message.username}</span>
<span className="text-gray-400 text-xs">
{moment(message.createdAt).format("DD MMM YYYY, h:mm A")}
</span>
</div>
<p className="text-gray-300 -mt-0.5">{message.text}</p>
</div>
</div>
);
};
export default Message;
components/MessageInput.tsx
:
import socketClient from "@/global/socketClient";
import React, { useState, useEffect } from "react";
import { IoSend } from "react-icons/io5";
const MessageInput: React.FC<{ roomId: string }> = ({ roomId }) => {
const [text, setText] = useState("");
const [username, setUsername] = useState<string | null>(null);
useEffect(() => {
if (typeof window !== "undefined") {
const storedUsername = localStorage.getItem("username");
setUsername(storedUsername);
}
}, []);
const handleSendMessage = () => {
if (text && username) {
socketClient.emit("message", {
roomId,
senderId: socketClient.id,
username,
text,
});
setText("");
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
handleSendMessage();
}
};
return (
<div className="py-4 px-6 bg-gray-700 flex">
<input
type="text"
className="flex-1 py-2 px-4 rounded bg-gray-600 text-white"
placeholder="Type your message..."
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
/>
<button
onClick={handleSendMessage}
className="bg-blue-600 hover:bg-blue-500 text-white font-bold py-2 px-4 rounded ml-2"
>
<IoSend />
</button>
</div>
);
};
export default MessageInput;
components/TopBar.tsx
:
import { GrPowerShutdown } from "react-icons/gr";
const TopBar: React.FC<{ roomId: string; onLogout: () => void }> = ({
roomId,
onLogout,
}) => {
return (
<div className="flex items-center justify-between p-4 bg-gray-700">
<h1 className="text-2xl font-bold">Room: {roomId}</h1>
<button
onClick={onLogout}
className="bg-red-600 hover:bg-red-500 text-white font-bold py-2 px-4 rounded"
>
<GrPowerShutdown />
</button>
</div>
);
};
export default TopBar;
then in next.config.mjs set the domain image used in the application:
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ["ui-avatars.com"],
},
};
export default nextConfig;
2.3 Running the Frontend
Run the Next.js application:
npm run dev
If you have run the project, it will look like this:
We’ve successfully built a real-time chat application using Express, TypeScript, Socket.IO, MongoDB for the backend, and Next.js with TailwindCSS for the frontend. We also covered how to handle real-time communication using Socket.IO and ensured a clean project structure with a service-oriented approach. This setup allows for scalable and maintainable development.
Feel free to customize and expand this project as needed, adding features like authentication, room creation, private messaging, etc.
Happy coding! 🎉