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.
Table of Contents
- 1. Introduction & Project Overview
- 2. Part 1: Node.js, Express, and Mongoose Setup
- 3. Part 2: Socket.io, Real-Time Chat & More Backend
- 4. Part 3: Redux Toolkit, RTK Query, & Frontend Integration
- 1. Setting up the Redux Store
- 2. Creating the Auth Slice
- 3. Building a Miscellaneous (UI) Slice
- 4. Checking Auth on the Frontend (Using Axios or RTK Query)
- 5. Login / Signup (Dispatching Redux actions)
- 6. Chat List with RTK Query
- 7. Search Users
- 8. Sending Friend Requests (Mutation)
- 9. Sockets and Real-Time Messages
- 10. Handling File Uploads (Attachments)
- 11. Additional Redux Patterns: createAsyncThunk
- 12. Putting it All Together
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:
- A fully functional backend with user authentication (login, signup), chat endpoints, and real-time messaging.
- A React frontend integrated with Redux Toolkit for state management, plus Socket.io for real-time events.
- 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
- Initialize the project:
mkdir chat-app cd chat-app npm init -y
- 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 frameworkmongoose
: to interact with MongoDBcookie-parser
: for reading cookies from requestsjsonwebtoken
: for creating and verifying JWTsbcrypt
: for hashing passwordstypescript
,ts-node-dev
, etc., if you want a TS setup
- Create a basic structure:
mkdir src touch src/index.ts
Explanation:
- We created a new directory
chat-app
, initializednpm
, 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 anindex.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):
- We import
express
,cors
, andcookie-parser
. - We create an instance of
express()
namedapp
. - We add middleware:
express.json()
to parse JSON in request bodies,cookieParser()
to parse cookies, andcors()
with a config that allows our React app onlocalhost:3000
to talk to the server and send credentials (cookies). - We set up a test route (
"/"
) to check if the API is up. - We define a
PORT
(default 5000) and start listening usingapp.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
andcatch
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):
- We import
mongoose
andbcrypt
. - We define a
UserDocument
interface that extendsDocument
from Mongoose, specifying our user fields and a method signaturecomparePassword()
. - We create a
userSchema
with fieldsusername
(unique) andpassword
. - We define a
pre("save")
hook that will hash the user’s password before saving if it’s new or modified. - We define a
comparePassword
method that checks the given password against the stored (hashed) password usingbcrypt.compare()
. - 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:
- We created a
generateToken
function that signs a JWT for 7 days using a secret. - In the
/signup
route, we validate the username/password, create aUser
, save to DB, then generate a JWT and send it back in a cookie (res.cookie("token", ...)
). - In the
/login
route, we find the user by username, compare passwords, then do the same JWT + cookie flow if they match. - In
/logout
, we simply clear thetoken
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 likejwt.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
- Install Socket.io:
npm install socket.io
- 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 Expressapp
. - We bind Socket.io to that server and configure CORS.
- We listen for the
connection
event and thedisconnect
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 usessocket.join(roomId)
to place that socket in a specific “room.” "send_message"
event includes data likeroomId
andmessage
. We then useio.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 possiblename
(for group chats), a booleanisGroup
, an array ofusers
(participants), and an array ofmessages
. - Each
message
includes thesender
, thecontent
, and acreatedAt
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 thecookie
header from the client’s handshake. - We verify that token with
jwt.verify
; if successful, we attachuserId
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 folderuploads/
. - In the route
POST /message/attachments
, we can handle uploading multiple files from the client, store them, and add references to the chat’smessages
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
- Create a
store.ts
(orstore.js
) inside aredux/
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;
- 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 useuseSelector
,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:
- We create an interface
AuthState
withuser
,isAdmin
, andloading
. - Our
initialState
setsuser
tonull
,isAdmin
tofalse
, andloading
totrue
(so we can show a loading spinner until we know the user’s status). - 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), andlogout
to reset auth state. - 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:
- In your
App
or main layout, call an API endpointGET /api/v1/user/me
. - If status 200, dispatch
userExists(user)
. - 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 dispatchuserNotExists()
. - 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
andpassword
. - 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
usingcreateApi
from RTK Query. We setbaseUrl
andcredentials: "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:
- Create a socket:
// src/socket.ts import { io } from "socket.io-client"; import { server } from "./config"; export const socket = io(server, { withCredentials: true, });
- 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
fromsocket.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 withsocket.emit
.
10. Handling File Uploads (Attachments)
- 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"); } };
- Server: handle
POST /api/v1/chat/message/attachments
withmulter
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, andPOST
to our/attachments
endpoint. - Make sure to set the
Content-Type
tomultipart/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.