Supercharge Your Web App: Building a Real-time Chat with Socket.IO, TanStack Query, Next.js 14, MongoDB, and Express
Are you ready to take your web development skills to the next level? In this tutorial, we'll dive into creating a cutting-edge real-time chat application using some of the most powerful and in-demand technologies in the industry. Whether you're a seasoned developer looking to expand your toolkit or an ambitious newcomer eager to learn, this project will give you hands-on experience with a modern, scalable tech stack.
Check out the live implementation here: K0 Club
You can find the complete source code on GitHub: K0 Club Repository
๐ What We're Building
We'll create a real-time chat application that allows users to:
Join different chat rooms (we'll call them "fights" in our example)
Send and receive messages in real-time
View message history
See user avatars and timestamps
Our app will showcase:
Real-time updates with Socket.IO
Efficient state management with TanStack Query (formerly React Query)
Server-side rendering and API routes with Next.js 14
Data persistence with MongoDB
A robust backend with Express
๐ ๏ธ The Tech Stack: Why It Matters
Before we dive into the code, let's break down why this particular combination of technologies is so powerful:
Socket.IO: Enables real-time, bidirectional communication between web clients and servers. It's the backbone of our chat functionality, allowing messages to be sent and received instantly.
TanStack Query: Simplifies data fetching, caching, and state management in React applications. It's a game-changer for handling server state, making our app more responsive and reducing unnecessary network requests.
Next.js 14: The latest version of the popular React framework, offering server-side rendering, API routes, and optimized performance out of the box. It's the perfect foundation for building modern web applications.
MongoDB: A flexible, scalable NoSQL database that's perfect for handling the dynamic, document-based data of a chat application.
Express: A minimal and flexible Node.js web application framework that provides a robust set of features for web and mobile applications. It's our choice for building a solid backend API.
This stack combines the best of modern web development: real-time capabilities, efficient state management, server-side rendering, and a flexible database, all built on a solid Node.js foundation.
๐๏ธ Project Setup
Let's start by setting up our project. We'll use create-next-app
to bootstrap our Next.js application, then add the necessary dependencies.
npx create-next-app@latest chat-app
cd chat-app
npm install express mongoose socket.io @tanstack/react-query next-auth dotenv cors
๐ฅ๏ธ Backend Magic: Express, Socket.IO, and MongoDB
Our backend will handle real-time communication, data persistence, and API endpoints. Let's break it down step by step.
1. Setting Up the Server
Create a server.js
file in your project root:
import express from "express";
import mongoose from "mongoose";
import { createServer } from "http";
import { Server } from "socket.io";
import cors from "cors";
import env from "dotenv";
// Load environment variables from .env file
env.config();
const app = express();
const server = createServer(app);
const io = new Server(server, {
cors: {
origin: process.env.CLIENT_URL,
methods: ["GET", "POST"],
},
});
// Enable CORS for the client URL
app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
// Parse JSON request bodies
app.use(express.json());
// Connect to MongoDB database
mongoose.connect(process.env.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true });
// Start the server on the specified port
const port = process.env.PORT || 5000;
server.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
This sets up our Express server with Socket.IO integration and connects to our MongoDB database.
2. Defining Our Data Models
Next, let's define our MongoDB schemas. Add these to your server.js
:
const messageSchema = new mongoose.Schema(
{
userId: { type: mongoose.Schema.Types.String, ref: "User" },
fightId: { type: mongoose.Schema.Types.String, ref: "Fight" },
message: { type: mongoose.Schema.Types.String, required: true },
createdAt: { type: Date, default: Date.now },
username: { type: mongoose.Schema.Types.String, required: true },
},
{ timestamps: true }
);
const Message = mongoose.model("Message", messageSchema);
const fightConversationSchema = new mongoose.Schema(
{
fightId: { type: mongoose.Schema.Types.String, ref: "Fight", required: true },
participants: { type: [mongoose.Schema.Types.String], ref: "User" },
messages: { type: [messageSchema], default: [] },
},
{ timestamps: true }
);
const FightConversation = mongoose.model("FightConversation", fightConversationSchema);
These schemas define the structure of our messages and conversations (fights) in the database.
3. Implementing Controller Functions
Now, let's add the logic for sending and retrieving messages:
const sendMessage = async (req, res) => {
try {
const { message, userId, username } = req.body;
const { fightId } = req.params;
// Find or create a conversation for the fight
let conversation = await FightConversation.findOne({ fightId });
if (!conversation) {
conversation = await FightConversation.create({
fightId,
participants: [userId],
});
}
// Add the user to the participants if not already included
if (!conversation.participants.includes(userId)) {
conversation.participants.push(userId);
await conversation.save();
}
// Create a new message
const newMessage = new Message({ userId, fightId, message, username });
// Add the new message to the conversation and save both
conversation.messages.push(newMessage);
await Promise.all([conversation.save(), newMessage.save()]);
// Emit the new message to the specific fight room
io.emit(fightId, newMessage);
res.status(201).json(newMessage);
} catch (error) {
console.log("Error in sendMessage controller: ", error.message);
res.status(500).json({ error: "Internal server error" });
}
};
const getMessages = async (req, res) => {
try {
const { fightId } = req.params;
// Find the conversation for the fight and populate the messages
const conversation = await FightConversation.findOne({ fightId }).populate("messages");
if (!conversation) return res.status(200).json([]);
const messages = conversation.messages;
res.status(200).json(messages);
} catch (error) {
console.log("Error in getMessages controller: ", error.message);
res.status(500).json({ error: "Internal server error" });
}
};
// Add these routes to your Express app
app.post("/api/fights/:fightId/messages", sendMessage);
app.get("/api/fights/:fightId/messages", getMessages);
These functions handle sending new messages and retrieving message history for a specific fight.
4. Socket.IO Magic
Finally, let's set up our Socket.IO event handlers:
const userSocketMap = {};
io.on("connection", (socket) => {
const userId = socket.handshake.query.userId;
if (userId) {
userSocketMap[userId] = socket.id;
}
// Handle joining a fight room
socket.on("joinFight", (fightId) => {
socket.join(fightId);
});
// Handle socket disconnection
socket.on("disconnect", () => {
for (const [key, value] of Object.entries(userSocketMap)) {
if (value === socket.id) {
delete userSocketMap[key];
}
}
});
});
This code manages socket connections, allowing users to join specific fight rooms and handling disconnects.
๐จ Frontend Finesse: Next.js and TanStack Query
Now that our backend is set up, let's create an engaging frontend with Next.js and TanStack Query.
1. Custom Hook for Fight Messages
Create a new file hooks/useFightMessages.ts
:
import { useEffect, useState } from "react";
import { Message } from "~/@types/message.type";
import { useSocketContext } from "~/utils/SocketContext";
export const useFightMessages = ({
fightId,
initialMessages,
}: {
fightId: string;
initialMessages: Message[] | undefined;
}) => {
const socket = useSocketContext();
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
if (initialMessages) {
setMessages(initialMessages);
}
}, [initialMessages]);
useEffect(() => {
if (!socket) return;
// Listen for new messages from the server
socket.on(`${fightId}`, (message) => {
setMessages((prevMessages) => [...
prevMessages, message] as Message[]);
});
// Clean up the event listener
return () => {
socket.off(`${fightId}`);
};
}, [socket, fightId]);
return messages;
};
This custom hook manages the state of our chat messages, integrating with Socket.IO for real-time updates.
2. ChatSection Component
Create a new file components/chat-section.tsx
:
import { ScrollArea } from "~/components/ui/scroll-area";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Textarea } from "./ui/textarea";
import { Button } from "./ui/button";
import { SendIcon } from "lucide-react";
import { useFightMessages } from "~/hooks/useFightMessages";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Message } from "~/@types/message.type";
import Loading from "~/app/loading";
import { env } from "~/env";
// Fetch messages for a specific fight
async function fetchFightMessages({ queryKey }: { queryKey: [string, string] }) {
const [, fightId] = queryKey;
return (await fetch(
`${env.NEXT_PUBLIC_SOCKET_URL}/api/fights/${fightId}/messages`,
).then((res) => res.json())) as Promise<Message[]>;
}
// Send a new message to the server
function sendMessage({
fightId,
messageInput,
userId,
username,
}: {
fightId: string;
messageInput: string;
userId: string;
username: string;
}) {
return fetch(`${env.NEXT_PUBLIC_SOCKET_URL}/api/fights/${fightId}/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message: messageInput, userId, username }),
}).then((res) => res.json());
}
export default function ChatSection({
fightId,
userId,
username,
}: {
fightId: string;
userId: string;
username: string;
}) {
const [messageInput, setMessageInput] = useState("");
// Fetch initial messages for the fight
const { data: initialMessages, isLoading } = useQuery({
queryKey: ["fightMessages", fightId],
queryFn: fetchFightMessages,
refetchOnWindowFocus: false,
});
// Use custom hook to manage real-time messages
const messages = useFightMessages({ fightId, initialMessages });
// Mutation for sending messages
const { mutate } = useMutation({
mutationFn: sendMessage,
});
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessageInput(e.target.value);
};
const handleSendMessage = () => {
mutate({ fightId, messageInput, userId, username });
setMessageInput("");
};
if (isLoading) {
return <Loading />;
}
return (
<section className="w-full py-12 md:py-24 lg:py-32" id="chat">
<div className="mx-auto max-w-4xl overflow-hidden rounded-xl bg-gray-100 p-4 shadow dark:bg-gray-800">
<ScrollArea className="h-96">
<div className="flex flex-col gap-4">
{messages.map((message: Message) => (
<div key={message._id} className="flex items-start gap-2">
<Avatar>
<AvatarImage alt="User" src="/placeholder-avatar.jpg" />
<AvatarFallback>
{message.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="font-medium">{message.username}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{new Date(message.createdAt).toLocaleString()}
</p>
<p>{message.message}</p>
</div>
</div>
))}
</div>
</ScrollArea>
<div className="mt-4 flex items-center gap-2">
<Textarea
className="h-16 flex-1"
placeholder="Type your message here..."
value={messageInput}
onChange={handleChange}
/>
<Button onClick={handleSendMessage}>
<SendIcon className="h-5 w-5" />
</Button>
</div>
</div>
</section>
);
}
This component handles displaying and sending messages, leveraging TanStack Query for efficient data fetching and state management.
3. FightPage Component
Create a new file app/fights/[fightId]/page.tsx
:
"use client";
import { useSession } from "next-auth/react";
import { useQuery } from "@tanstack/react-query";
import { Challenge } from "~/@types/fight.type";
import { MainFightPage } from "~/components/main-fight-page";
import { Unauthorized } from "~/components/unauthorized";
import Loading from "~/app/loading";
// Fetch data for a specific fight
const fetchFightData = async ({ queryKey }: { queryKey: [string, string] }) => {
const [, fightId] = queryKey;
const response = await fetch(`/api/fight/${fightId}`);
if (!response.ok) {
throw new Error("Failed to fetch fight data");
}
return response.json() as Promise<Challenge>;
};
const FightPage = ({
params,
}: {
params: {
fightId: string;
};
}) => {
const { fightId } = params;
const { data: session } = useSession();
const { data: FightData, isLoading } = useQuery({
queryKey: ["fight", fightId],
queryFn: fetchFightData,
enabled: !!session,
});
if (!session) {
return <Unauthorized />;
}
if (!FightData || isLoading) {
return <Loading />;
}
return (
<div>
<MainFightPage
FightData={FightData}
session={
session as {
user: {
id: string;
name: string;
email: string;
image: string;
username: string;
};
}
}
/>
</div>
);
};
export default FightPage;
This page component fetches fight data and renders the chat interface, showcasing Next.js 14's new app directory structure and TanStack Query's integration.
๐ฌ Putting It All Together
Now that we've built our backend and frontend components, let's bring everything together and get our app running!
Set up your environment variables in a .env
file:
MONGODB_URI=your_mongodb_connection_string
CLIENT_URL=http://localhost:3000
NEXT_PUBLIC_SOCKET_URL=http://localhost:5000
PORT=5000
NEXTAUTH_SECRET=your_nextauth_secret
NEXTAUTH_URL=http://localhost:3000
Replace your_mongodb_connection_string
with your actual MongoDB connection string, and your_nextauth_secret
with a secure random string for NextAuth.js.
Update your package.json
to include scripts for running both the server and the Next.js app:
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"server": "node server.js",
"dev:full": "concurrently \"npm run server\" \"npm run dev\""
}
Install concurrently
to run both the server and client simultaneously:
npm install --save-dev concurrently
Set up a basic Next.js API route for authentication. Create a file pages/api/auth/[...nextauth].js
:
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
export default NextAuth({
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
// Add your own logic here to find the user from your database
if (credentials.username === "demo" && credentials.password === "password") {
return { id: 1, name: "Demo User", email: "demo@example.com" };
}
return null;
}
})
],
// Add your own custom pages here if needed
pages: {
signIn: "/auth/signin",
},
});
This sets up a basic authentication system. In a production app, you'd want to implement proper user registration and database integration.
Create a SocketContext to provide socket connection to your components. Create a new file utils/SocketContext.tsx
:
import React, { createContext, useContext, useEffect, useState } from 'react';
import io, { Socket } from 'socket.io-client';
import { useSession } from 'next-auth/react';
const SocketContext = createContext<Socket | null>(null);
export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [socket, setSocket] = useState<Socket | null>(null);
const { data: session } = useSession();
useEffect(() => {
if (session?.user?.id) {
const newSocket = io(process.env.NEXT_PUBLIC_SOCKET_URL as string,
{
query: { userId: session.user.id },
});
setSocket(newSocket);
return () => {
newSocket.close();
};
}
}, [session]);
return (
<SocketContext.Provider value={socket}>
{children}
</SocketContext.Provider>
);
};
export const useSocketContext = () => useContext(SocketContext);
Wrap your app with necessary providers. Update your pages/_app.tsx
:
import type { AppProps } from 'next/app';
import { SessionProvider } from 'next-auth/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SocketProvider } from '~/utils/SocketContext';
const queryClient = new QueryClient();
function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
return (
<SessionProvider session={session}>
<QueryClientProvider client={queryClient}>
<SocketProvider>
<Component {...pageProps} />
</SocketProvider>
</QueryClientProvider>
</SessionProvider>
);
}
export default MyApp;
Run your application:
npm run dev:full
This command will start both your Express server and Next.js application concurrently.
๐ Congratulations!
You've now built a real-time chat application using a powerful, modern tech stack! Here's a quick recap of what we've accomplished:
Set up a robust backend with Express and Socket.IO for real-time communication
Integrated MongoDB for data persistence
Created a sleek frontend with Next.js 14 and TanStack Query
Implemented real-time messaging with optimistic updates
Added user authentication with NextAuth.js
๐ Next Steps
To take your application to the next level, consider implementing these features:
User registration and profile management
Message editing and deletion
File uploads and image sharing
Read receipts and typing indicators
Push notifications for new messages
Message search functionality
Rate limiting and spam protection
๐ Conclusion
Building this real-time chat application has given you hands-on experience with some of the most in-demand technologies in web development. You've created a foundation that can be extended into a full-fledged messaging platform, social media app, or collaborative tool.
Remember, the key to mastering these technologies is practice and continuous learning. Don't be afraid to experiment, break things, and rebuild them. Every error is a learning opportunity!
I hope you found this tutorial helpful and engaging. If you have any questions or want to share your progress, feel free to leave a comment below. Happy coding, and may your chat app bring people together in exciting new ways!
Check out the live implementation here: K0 Club
You can find the complete source code on GitHub: K0 Club Repository
Subscribe to my newsletter
Read articles from Dhruv Khara directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by