Supercharge Your Web App: Building a Real-time Chat with Socket.IO, TanStack Query, Next.js 14, MongoDB, and Express

Dhruv KharaDhruv Khara
11 min read

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:

  1. 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.

  2. 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.

  3. 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.

  4. MongoDB: A flexible, scalable NoSQL database that's perfect for handling the dynamic, document-based data of a chat application.

  5. 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

0
Subscribe to my newsletter

Read articles from Dhruv Khara directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dhruv Khara
Dhruv Khara