Below is the entire tutorial (Parts 1, 2, and 3) combined into a single post with a unified Table of Contents, exactly as previously provided—nothing is omitted or changed, especially the code explanations.

1. Introduction & Project Overview

In this tutorial series (Parts 1, 2, and 3), we will build a full-stack chat application using:

  • Node.js + Express (for REST APIs),
  • Mongoose (for MongoDB interactions),
  • JWT & Cookies (for authentication),
  • Socket.io (for real-time messaging),
  • React on the frontend (with TypeScript if desired),
  • Redux Toolkit (to manage global state),
  • RTK Query (for data fetching/caching),
  • and optional file uploads (images, videos, etc.) to enhance the chat experience.

By the end of the tutorial, you will have:

  1. A fully functional backend with user authentication (login, signup), chat endpoints, and real-time messaging.
  2. A React frontend integrated with Redux Toolkit for state management, plus Socket.io for real-time events.
  3. A basic admin system or advanced UI toggles if needed, including group chats, search functionality, friend requests, etc.

2. Part 1: Node.js, Express, and Mongoose Setup

1. Project Initialization

  1. Initialize the project:
    mkdir chat-app
    cd chat-app
    npm init -y
                    
  2. Install dependencies:
    npm install express mongoose cors cookie-parser jsonwebtoken bcrypt
    npm install --save-dev typescript ts-node-dev @types/express @types/node @types/cookie-parser
                    
    • express: our Node.js framework
    • mongoose: to interact with MongoDB
    • cookie-parser: for reading cookies from requests
    • jsonwebtoken: for creating and verifying JWTs
    • bcrypt: for hashing passwords
    • typescript, ts-node-dev, etc., if you want a TS setup
  3. Create a basic structure:
    mkdir src
    touch src/index.ts
                    

Explanation:

  • We created a new directory chat-app, initialized npm, and installed the necessary dependencies for Express, Mongoose, and authentication (jsonwebtoken, bcrypt, cookie-parser for cookies).
  • Also, we installed dev dependencies like typescript, ts-node-dev, and type declarations for Node and Express if we are using TypeScript.
  • Finally, we made our main src directory and an index.ts file where our server code will live.

2. Basic Express Server

// src/index.ts
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";

const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(cors({
  origin: ["http://localhost:3000"], // your React app
  credentials: true
}));

app.get("/", (req, res) => {
  res.send("API is running...");
});

// Start server
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});
        

Explanation of code (line by line):

  1. We import express, cors, and cookie-parser.
  2. We create an instance of express() named app.
  3. We add middleware: express.json() to parse JSON in request bodies, cookieParser() to parse cookies, and cors() with a config that allows our React app on localhost:3000 to talk to the server and send credentials (cookies).
  4. We set up a test route ("/") to check if the API is up.
  5. We define a PORT (default 5000) and start listening using app.listen.

3. Connecting MongoDB with Mongoose

import mongoose from "mongoose";

mongoose.connect(process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/chat-app")
  .then(() => console.log("MongoDB connected"))
  .catch((err) => console.log(err));

Now your server is connected to MongoDB.

Explanation:

  • We use mongoose.connect with a fallback local URI.
  • then and catch let us log success or errors.
  • Make sure your local MongoDB is running on port 27017 or your remote URI is correct.

4. Basic User Model

// src/models/User.ts
import mongoose, { Schema, Document } from "mongoose";
import bcrypt from "bcrypt";

interface UserDocument extends Document {
  username: string;
  password: string;
  comparePassword(password: string): Promise;
}

const userSchema = new Schema({
  username: { type: String, required: true, unique: true },
  password: { type: String, required: true },
});

// Before saving, hash the password
userSchema.pre("save", async function(next) {
  if (!this.isModified("password")) return next();
  this.password = await bcrypt.hash(this.password, 10);
  next();
});

// Compare password method
userSchema.methods.comparePassword = function (password: string) {
  return bcrypt.compare(password, this.password);
};

export default mongoose.model("User", userSchema);

Explanation (step by step):

  1. We import mongoose and bcrypt.
  2. We define a UserDocument interface that extends Document from Mongoose, specifying our user fields and a method signature comparePassword().
  3. We create a userSchema with fields username (unique) and password.
  4. We define a pre("save") hook that will hash the user’s password before saving if it’s new or modified.
  5. We define a comparePassword method that checks the given password against the stored (hashed) password using bcrypt.compare().
  6. Finally, we export the model named "User".

5. Authentication with JWT & Cookies

  • Generate JWT (in a helper function):
    import jwt from "jsonwebtoken";
    
    export const generateToken = (id: string) => {
      return jwt.sign({ id }, process.env.JWT_SECRET || "secretkey", {
        expiresIn: "7d",
      });
    };
    
  • Auth routes:
    // src/routes/auth.ts
    import express from "express";
    import User from "../models/User";
    import { generateToken } from "../utils/jwt";
    
    const router = express.Router();
    
    // Signup
    router.post("/signup", async (req, res) => {
      try {
        const { username, password } = req.body;
        if (!username || !password) {
          return res.status(400).json({ message: "Missing fields" });
        }
        const user = new User({ username, password });
        await user.save();
    
        // create token
        const token = generateToken(user._id.toString());
        // set cookie
        res.cookie("token", token, { httpOnly: true });
        res.json({ message: "Signup successful", user });
      } catch (err) {
        res.status(500).json({ message: err.message });
      }
    });
    
    // Login
    router.post("/login", async (req, res) => {
      try {
        const { username, password } = req.body;
        const user = await User.findOne({ username });
        if (!user) return res.status(401).json({ message: "Invalid credentials" });
    
        const isMatch = await user.comparePassword(password);
        if (!isMatch) return res.status(401).json({ message: "Invalid credentials" });
    
        const token = generateToken(user._id.toString());
        res.cookie("token", token, { httpOnly: true });
        res.json({ message: "Login successful", user });
      } catch (err) {
        res.status(500).json({ message: err.message });
      }
    });
    
    // Logout
    router.post("/logout", (req, res) => {
      res.clearCookie("token");
      res.json({ message: "Logout successful" });
    });
    
    export default router;
    

Explanation:

  1. We created a generateToken function that signs a JWT for 7 days using a secret.
  2. In the /signup route, we validate the username/password, create a User, save to DB, then generate a JWT and send it back in a cookie (res.cookie("token", ...)).
  3. In the /login route, we find the user by username, compare passwords, then do the same JWT + cookie flow if they match.
  4. In /logout, we simply clear the token cookie on the client side.

6. Sample API Routes (User, Chat, etc.)

  • User route to get current user:
    // src/routes/user.ts
    import express from "express";
    import User from "../models/User";
    import authMiddleware from "../middleware/auth";
    
    const router = express.Router();
    
    // Protected route to get current user
    router.get("/me", authMiddleware, async (req, res) => {
      const userId = (req as any).userId;
      const user = await User.findById(userId).select("-password");
      res.json({ user });
    });
    
    export default router;
    
  • Chat route (placeholder):
    // src/routes/chat.ts
    import express from "express";
    
    const router = express.Router();
    
    router.get("/my", async (req, res) => {
      // Return a list of chats for the logged in user
      res.json({ chats: [] });
    });
    
    // more CRUD for chats...
    
    export default router;
    

Explanation:

  • We show an example user route that returns the current logged-in user if the request is authenticated (authMiddleware).
  • The chat route is a placeholder for more chat logic.

7. Project Structure Recap

chat-app/
  ├─ src/
  │   ├─ index.ts
  │   ├─ models/
  │   │   └─ User.ts
  │   ├─ routes/
  │   │   ├─ auth.ts
  │   │   ├─ user.ts
  │   │   └─ chat.ts
  │   ├─ middleware/
  │   │   └─ auth.ts
  │   ├─ utils/
  │   │   └─ jwt.ts
  │   └─ ...
  └─ package.json

Explanation:

  • models/ folder contains Mongoose models.
  • routes/ folder contains Express routers for different functionalities (auth, user, chat).
  • middleware/ can hold custom middleware for authentication, etc.
  • utils/ is for helpers like jwt.ts.

This completes Part 1 of our tutorial, setting up a basic Node.js + Express + Mongoose app with JWT cookie authentication and minimal routes.


3. Part 2: Socket.io, Real-Time Chat & More Backend

1. Setting up Socket.io on the Server

  1. Install Socket.io:
    npm install socket.io
    
  2. Modify your index.ts:
    import http from "http";
    import { Server } from "socket.io";
    import app from "./app"; // if you separated Express app in app.ts
    
    const server = http.createServer(app);
    const io = new Server(server, {
      cors: {
        origin: ["http://localhost:3000"],
        credentials: true,
      },
    });
    
    // Socket.io connection
    io.on("connection", (socket) => {
      console.log("Socket connected:", socket.id);
      socket.on("disconnect", () => {
        console.log("Socket disconnected:", socket.id);
      });
    });
    
    const PORT = process.env.PORT || 5000;
    server.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
    

Explanation:

  • We create an http server from our Express app.
  • We bind Socket.io to that server and configure CORS.
  • We listen for the connection event and the disconnect event to track socket connections.
  • Finally, we start the server on the desired port.

2. Real-Time Events (Join Room, Send Message, Typing, etc.)

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

  socket.on("join_room", (roomId) => {
    socket.join(roomId);
  });

  socket.on("send_message", (data) => {
    // data = { roomId, message, senderId }
    // broadcast to room
    io.to(data.roomId).emit("receive_message", data);
  });

  socket.on("typing", (roomId) => {
    socket.to(roomId).emit("typing_notification");
  });
});

Explanation:

  • When a client emits "join_room", the server uses socket.join(roomId) to place that socket in a specific “room.”
  • "send_message" event includes data like roomId and message. We then use io.to(roomId).emit(...) to broadcast to everyone in that room, so they can listen for "receive_message".
  • A "typing" event can be sent to other members of the room to show a “typing” indicator.

3. Updating the Chat Model & Controllers

// src/models/Chat.ts
import mongoose, { Schema, Document } from "mongoose";

interface ChatDocument extends Document {
  name?: string;
  isGroup: boolean;
  users: mongoose.Types.ObjectId[];
  messages: {
    sender: mongoose.Types.ObjectId;
    content: string;
    createdAt: Date;
  }[];
}

const chatSchema = new Schema<ChatDocument>({
  name: { type: String },
  isGroup: { type: Boolean, default: false },
  users: [{ type: Schema.Types.ObjectId, ref: "User" }],
  messages: [
    {
      sender: { type: Schema.Types.ObjectId, ref: "User" },
      content: { type: String },
      createdAt: { type: Date, default: Date.now },
    },
  ],
});

export default mongoose.model<ChatDocument>("Chat", chatSchema);

Then in your chat routes:

router.post("/create", authMiddleware, async (req, res) => {
  // create new chat (one-to-one or group)
});
router.post("/send-message", authMiddleware, async (req, res) => {
  // push message to chat model
});

Explanation:

  • We define a Chat model with a possible name (for group chats), a boolean isGroup, an array of users (participants), and an array of messages.
  • Each message includes the sender, the content, and a createdAt timestamp.
  • In the routes, you can handle logic for creating a new chat or adding a new message to the existing chat’s messages array.

4. Group Chats & Invitations

For group chats, you can store a isGroup: true and a name. You can add or remove members if you are the admin of that group.

Explanation:

  • Typically, you’d have an endpoint to create a group, invite members, remove them, etc.
  • You might also track roles (admin, moderator, etc.) for controlling group members.

5. Integrating Socket.io with Authentication

import cookie from "cookie";
import jwt from "jsonwebtoken";

io.use((socket, next) => {
  const { token } = cookie.parse(socket.handshake.headers.cookie || "");
  if (!token) return next(new Error("No auth token"));

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET || "secretkey");
    (socket as any).userId = (decoded as any).id;
    next();
  } catch (err) {
    next(new Error("Invalid token"));
  }
});

Explanation:

  • We use the cookie library to parse the cookie header from the client’s handshake.
  • We verify that token with jwt.verify; if successful, we attach userId to the socket.
  • If it fails, we throw an error to block the connection.

6. File Uploads on the Backend

Install multer (or busboy) to handle file uploads (images, etc.). Then store or upload them to a service (e.g., Cloudinary, S3).

// example snippet
import multer from "multer";
const upload = multer({ dest: "uploads/" }); // or custom config

router.post("/message/attachments", authMiddleware, upload.array("files"), async (req, res) => {
  // handle uploading
});

Explanation:

  • We define a multer upload instance with a destination folder uploads/.
  • In the route POST /message/attachments, we can handle uploading multiple files from the client, store them, and add references to the chat’s messages if needed.

7. Wrapping Up the Backend

At this point, you have:

  • Express for routes
  • Mongoose for MongoDB
  • Auth with JWT & cookies
  • Socket.io for real-time
  • Possibly a Chat model and group chat logic
  • Potential file upload integration

Part 2 ended here, focusing on the real-time socket integration and more advanced chat features on the backend.


4. Part 3: Redux Toolkit, RTK Query, & Frontend Integration

Below is Part 3 of the chat application tutorial, in English, covering how to integrate the frontend using React, Redux Toolkit, RTK Query (for fetching), Socket.io (client side), and so on.

1. Setting up the Redux Store

  1. Create a store.ts (or store.js) inside a redux/ folder
    // src/redux/store.ts
    import { configureStore } from "@reduxjs/toolkit";
    // Import your reducers here
    import authReducer from "./reducers/authSlice";
    import miscReducer from "./reducers/miscSlice";
    // ... Also import your RTK Query API if you're using one
    import { api } from "./api/api"; // example: RTK Query instance
    
    const store = configureStore({
      reducer: {
        auth: authReducer,
        misc: miscReducer,
        // If you have RTK Query:
        [api.reducerPath]: api.reducer,
      },
      middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware().concat(api.middleware), // if using RTK Query
    });
    
    export default store;
    export type RootState = ReturnType<typeof store.getState>;
    export type AppDispatch = typeof store.dispatch;
    
  2. Wrap your main React App with the <Provider>
    // src/main.tsx or src/index.tsx
    import React from "react";
    import ReactDOM from "react-dom/client";
    import { Provider } from "react-redux";
    import store from "./redux/store";
    
    import App from "./App"; // or main router file
    
    ReactDOM.createRoot(document.getElementById("root")!).render(
      <Provider store={store}>
        <App />
      </Provider>
    );
    

Explanation:

  • We create our Redux store using configureStore() from Redux Toolkit.
  • We combine our auth reducer and misc reducer, and optionally an RTK Query api.reducer.
  • We then set up our Provider so every component in our React app can use useSelector, useDispatch, and other Redux features.

2. Creating the Auth Slice

// src/redux/reducers/authSlice.ts
import { createSlice } from "@reduxjs/toolkit";

interface AuthState {
  user: any;       // or define type
  isAdmin: boolean;
  loading: boolean;
}

const initialState: AuthState = {
  user: null,
  isAdmin: false,
  loading: true,
};

const authSlice = createSlice({
  name: "auth",
  initialState,
  reducers: {
    // sets the user object if user is logged in
    userExists(state, action) {
      state.user = action.payload; // or handle more fields
      state.loading = false;
    },
    // sets user to null if not logged in
    userNotExists(state) {
      state.user = null;
      state.loading = false;
    },
    // if needed, to set isAdmin to true
    setAdmin(state, action) {
      state.isAdmin = !!action.payload; 
    },
    // handle logging out
    logout(state) {
      state.user = null;
      state.isAdmin = false;
    },
  },
});

export const {
  userExists,
  userNotExists,
  setAdmin,
  logout,
} = authSlice.actions;

export default authSlice.reducer;

Explanation:

  1. We create an interface AuthState with user, isAdmin, and loading.
  2. Our initialState sets user to null, isAdmin to false, and loading to true (so we can show a loading spinner until we know the user’s status).
  3. Our slice has reducers like userExists (sets user data when the user is authenticated), userNotExists (clears user if not logged in), setAdmin (optionally sets admin mode), and logout to reset auth state.
  4. We export those actions and the reducer.

3. Building a Miscellaneous (UI) Slice

// src/redux/reducers/miscSlice.ts
import { createSlice } from "@reduxjs/toolkit";

interface MiscState {
  isNewGroup: boolean;
  isMemberDialog: boolean;
  isNotification: boolean;
  isMobileMenu: boolean;
  isSearch: boolean;
  isFileMenu: boolean;
  isDeleteMenu: boolean;
  uploadingLoader: boolean;
  selectedDeleteChat: {
    chatId: string;
    isGroup: boolean;
  };
  // etc.
}

const initialState: MiscState = {
  isNewGroup: false,
  isMemberDialog: false,
  isNotification: false,
  isMobileMenu: false,
  isSearch: false,
  isFileMenu: false,
  isDeleteMenu: false,
  uploadingLoader: false,
  selectedDeleteChat: {
    chatId: "",
    isGroup: false,
  },
};

const miscSlice = createSlice({
  name: "misc",
  initialState,
  reducers: {
    setIsNewGroup(state, action) {
      state.isNewGroup = !!action.payload;
    },
    setIsMemberDialog(state, action) {
      state.isMemberDialog = !!action.payload;
    },
    setIsNotification(state, action) {
      state.isNotification = !!action.payload;
    },
    setIsMobileMenu(state, action) {
      state.isMobileMenu = !!action.payload;
    },
    setIsSearch(state, action) {
      state.isSearch = !!action.payload;
    },
    setIsFileMenu(state, action) {
      state.isFileMenu = !!action.payload;
    },
    setIsDeleteMenu(state, action) {
      state.isDeleteMenu = !!action.payload;
    },
    setUploadingLoader(state, action) {
      state.uploadingLoader = !!action.payload;
    },
    setSelectedDeleteChat(state, action) {
      // expecting { chatId: string, isGroup: boolean }
      state.selectedDeleteChat = action.payload;
    },
  },
});

export const {
  setIsNewGroup,
  setIsMemberDialog,
  setIsNotification,
  setIsMobileMenu,
  setIsSearch,
  setIsFileMenu,
  setIsDeleteMenu,
  setUploadingLoader,
  setSelectedDeleteChat,
} = miscSlice.actions;
export default miscSlice.reducer;

Explanation:

  • This slice manages “miscellaneous” UI states, such as whether a new group dialog is open, if a user is searching, if a notification panel is open, or if a file upload is in progress (uploadingLoader).
  • This helps keep these small states out of more complex slices.

4. Checking Auth on the Frontend (Using Axios or RTK Query)

We want to see if the user is already logged in (has a valid cookie or token). Typically:

  1. In your App or main layout, call an API endpoint GET /api/v1/user/me.
  2. If status 200, dispatch userExists(user).
  3. If status 401, dispatch userNotExists().

Using Axios inside a useEffect:

// Example: src/pages/Home.tsx or a main layout
import axios from "axios";
import { userExists, userNotExists } from "../redux/reducers/authSlice";
import { useDispatch } from "react-redux";
import { useEffect } from "react";
import { server } from "../config"; // your env config

function Home() {
  const dispatch = useDispatch();

  useEffect(() => {
    axios
      .get(`${server}/api/v1/user/me`, { withCredentials: true })
      .then((res) => {
        // if successful, dispatch user data
        dispatch(userExists(res.data.user)); 
      })
      .catch(() => {
        // not logged in
        dispatch(userNotExists());
      });
  }, []);

  return (
    <div>
      {/* your home layout */}
    </div>
  );
}

export default Home;

Alternatively, using RTK Query:

// src/redux/api/authApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { server } from "../../config";

export const authApi = createApi({
  reducerPath: "authApi",
  baseQuery: fetchBaseQuery({
    baseUrl: `${server}/api/v1`,
    credentials: "include",
  }),
  endpoints: (builder) => ({
    getUserMe: builder.query({
      query: () => `/user/me`,
    }),
  }),
});

export const { useGetUserMeQuery } = authApi;
function Home() {
  const { data, error, isLoading } = useGetUserMeQuery(undefined);

  useEffect(() => {
    if (!isLoading) {
      if (error) dispatch(userNotExists());
      else if (data?.user) dispatch(userExists(data.user));
    }
  }, [data, error, isLoading]);

  return <div>Home layout</div>;
}

Explanation:

  • We either do a manual Axios call in a useEffect or use RTK Query’s generated hooks to fetch the current user.
  • If successful, we dispatch userExists(...). If it fails (401 unauthorized), we dispatch userNotExists().
  • This ensures our Redux store knows whether we’re logged in.

5. Login / Signup (Dispatching Redux actions)

Simple login form example:

// src/pages/Login.tsx
import axios from "axios";
import { useState } from "react";
import { server } from "../config";
import { userExists } from "../redux/reducers/authSlice";
import { useDispatch } from "react-redux";
import { toast } from "react-hot-toast";

function Login() {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const dispatch = useDispatch();

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      // Optionally set loader true
      const { data } = await axios.post(
        `${server}/api/v1/user/login`,
        { username, password },
        { withCredentials: true }
      );
      // If successful
      dispatch(userExists(data.user)); 
      toast.success(data?.message || "Logged in successfully");
    } catch (err: any) {
      toast.error(err?.response?.data?.message || "Invalid username or password");
    } finally {
      // Optionally stop loader
    }
  };

  return (
    <form onSubmit={handleLogin}>
      <input
        type="text"
        placeholder="Username"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Log In</button>
    </form>
  );
}

export default Login;

Explanation:

  • We have a login form that captures username and password.
  • On submit, we call our backend login endpoint with Axios (including { withCredentials: true } to send and receive cookies).
  • If successful, we dispatch userExists(data.user) and show a success toast. If an error occurs, we show an error toast.

6. Chat List with RTK Query

We want to get the user’s chat list from GET /api/v1/chat/my:

// src/redux/api/chatApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { server } from "../../config";

export const chatApi = createApi({
  reducerPath: "chatApi",
  baseQuery: fetchBaseQuery({
    baseUrl: `${server}/api/v1`,
    credentials: "include",
  }),
  tagTypes: ["Chat", "User"],
  endpoints: (builder) => ({
    getMyChats: builder.query<any, void>({
      query: () => `/chat/my`,
      providesTags: ["Chat"],
    }),
    // for searching users, sending requests, etc.
  }),
});

export const { useGetMyChatsQuery } = chatApi;

Then in your layout:

import { useGetMyChatsQuery } from "../redux/api/chatApi";
import { Skeleton } from "@mui/material";

function Layout() {
  const { data, error, isLoading } = useGetMyChatsQuery();

  if (isLoading) return <Skeleton variant="rectangular" width="100%" height={50} />;
  if (error) {
    return <div>Some error occurred</div>;
  }

  return (
    <div>
      {data?.chats?.map((chat: any) => (
        <div key={chat._id}>{chat.name || "No Name"}</div>
      ))}
    </div>
  );
}

export default Layout;

Explanation:

  • We define a chatApi using createApi from RTK Query. We set baseUrl and credentials: "include" to handle cookies.
  • We create a query endpoint getMyChats that fetches /chat/my.
  • Then in a component, we use the generated hook useGetMyChatsQuery to fetch data and handle loading, error, or success states.

7. Search Users

An endpoint GET /api/v1/user/search?name=... returns an array of users. Using RTK Query:

getSearchUsers: builder.query<any, string>({
  query: (name) => `/user/search?name=${name}`,
  providesTags: ["User"],
}),

Then in your component:

const [triggerSearch] = useLazyGetSearchUsersQuery();

const handleSearch = async (name: string) => {
  if (!name) return;
  const result = await triggerSearch(name).unwrap();
  // do something with result
};

Explanation:

  • We create a “lazy” query with RTK Query so we can call triggerSearch(name) only when the user actually types something in.
  • The .unwrap() method helps us handle the raw data or catch errors easily in a try/catch block.

8. Sending Friend Requests (Mutation)

Example of an RTK Query mutation:

sendFriendRequest: builder.mutation<any, { userId: string }>({
  query: (body) => ({
    url: `/user/send-request`,
    method: "POST",
    body,
    credentials: "include",
  }),
  invalidatesTags: ["User"], 
}),

Usage:

const [sendFriendRequest, { isLoading }] = useSendFriendRequestMutation();

const handleSendRequest = async (userId: string) => {
  try {
    const result = await sendFriendRequest({ userId }).unwrap();
    toast.success(result?.message || "Request sent");
  } catch (err: any) {
    toast.error(err?.data?.message || "Error sending request");
  }
};

Explanation:

  • We define a mutation sendFriendRequest that POSTs to /user/send-request with a JSON body.
  • We set invalidatesTags: ["User"] to refetch or invalidate user data if needed.
  • In the component, we call sendFriendRequest({ userId }), handle the result, and show toasts accordingly.

9. Sockets and Real-Time Messages

Socket.io client:

  1. Create a socket:
    // src/socket.ts
    import { io } from "socket.io-client";
    import { server } from "./config";
    
    export const socket = io(server, {
      withCredentials: true,
    });
    
  2. Use it in your main layout or a context:
    import { socket } from "../socket";
    import { useEffect } from "react";
    
    function ChatRoom({ chatId }) {
      useEffect(() => {
        socket.on("NEW_MESSAGE", (data) => {
          console.log("Received new message:", data);
        });
        return () => {
          socket.off("NEW_MESSAGE");
        };
      }, []);
    
      const sendMessage = () => {
        socket.emit("NEW_MESSAGE", { chatId, message: "Hi!" });
      };
    
      return <button onClick={sendMessage}>Send Message</button>;
    }
    
    export default ChatRoom;
    

Explanation:

  • We import io from socket.io-client and connect to our server URL.
  • We set withCredentials: true to include any cookie-based auth tokens.
  • We listen for a custom event ("NEW_MESSAGE") and handle sending messages with socket.emit.

10. Handling File Uploads (Attachments)

  1. Front-end:
    const handleFileUpload = async (files: FileList) => {
      if (!files.length) return;
      const formData = new FormData();
      formData.append("chatId", chatId);
      for (let i = 0; i < files.length; i++) {
        formData.append("files", files[i]); 
      }
      try {
        const { data } = await axios.post(
          `${server}/api/v1/chat/message/attachments`,
          formData,
          {
            withCredentials: true,
            headers: { "Content-Type": "multipart/form-data" },
          }
        );
        toast.success(data?.message || "File(s) sent");
      } catch (err: any) {
        toast.error(err?.response?.data?.message || "Error sending files");
      }
    };
    
  2. Server: handle POST /api/v1/chat/message/attachments with multer or any file parser. Then broadcast with Socket.io to the chat’s members.

Explanation:

  • In the frontend, we create a FormData object, append each file, and POST to our /attachments endpoint.
  • Make sure to set the Content-Type to multipart/form-data.
  • The server can respond with success, and we can notify the user with a toast.

11. Additional Redux Patterns: createAsyncThunk

Example usage if you prefer a thunk over RTK Query:

// src/redux/thunks/admin.ts
import { createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";
import { server } from "../../config";

export const adminLogin = createAsyncThunk(
  "admin/login",
  async (secretKey: string, { rejectWithValue }) => {
    try {
      const { data } = await axios.post(
        `${server}/api/v1/admin/verify`,
        { secretKey },
        { withCredentials: true }
      );
      return data.admin;
    } catch (err: any) {
      return rejectWithValue(err?.response?.data?.message || "Admin Key is invalid");
    }
  }
);

Then handle in a slice with extraReducers.

Explanation:

  • createAsyncThunk is a Redux Toolkit method for handling async calls in a more “traditional” Redux approach, giving you pending/fulfilled/rejected states.
  • In your slice, you’d use extraReducers to handle those states and update loading flags or store data as needed.

12. Putting it All Together

By now, we have:

  • Backend: Node.js, Express, Mongoose, authentication routes, chat routes, file uploads, Socket.io for real-time.
  • Frontend: React + Redux Toolkit, slices for auth/UI, RTK Query or Axios for requests, integrated Socket.io client for real-time messages.
  • Additional admin or group chat features (friend requests, group management, attachments).

Congratulations! You now have a basic but fully functional real-time chat application. Feel free to expand on the UI/UX with additional styling frameworks, add more advanced features (read receipts, editing messages, push notifications, etc.), and finally deploy your app to platforms like Render, Railway, AWS, or your own Docker server.