Building a Simple ChatApp Using Next.js , Typescript, Socket.io and Livekit.

Abhishek Mishra
10 min readNov 23, 2023

--

Chat Application with TS , Next.js, Socket.io , redux and Node.js

Here is simple guide for making Chat app with video call feature using Next.js , Typescript, Socket.io ,Redux and Livekit.

Step 1: Create a new Next.js app using typescript by running the following command in your terminal:

Note: In this tutorial pages folder structure is used.

npx create-next-app chat-app

Step 2: Once the project is set up, navigate into the project directory:

cd chat-app

Step 3: Install all dependencies using following command is terminal:

npm i reduxjs/toolkit redux-persist livekit-client livekit-server-sdk react-redux socket.io socket.io-client

Step 4: Create a folder redux and inside redux create a file store.ts and folder with slices and inside slice create two file with name approved.ts and user.ts with these code:

//store.ts

import { configureStore } from '@reduxjs/toolkit';
import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import userReducer from './slices/user';
import approved from './slices/approved';
const persistConfigOrder = {
key: 'user',
storage,
};
const persistConfigApproved={
key: 'approved',
storage,
}

const persisteduserReducer = persistReducer(persistConfigOrder, userReducer);
const persistedapprovedReducer = persistReducer(persistConfigApproved, approved);

const store = configureStore({
reducer: {
user: persisteduserReducer,
approved:persistedapprovedReducer
},
});

const persistor = persistStore(store);

export { store, persistor };
//approved.ts
import { createSlice } from '@reduxjs/toolkit';

export const approved = createSlice({
name: 'approved',
initialState: {
approve:[],

},
reducers: {
setAooroved:(state,action)=>{
state.approve = action.payload;
},

},
});

export const {setAooroved} = approved.actions;

export default approved.reducer;
//user.ts
import { createSlice } from '@reduxjs/toolkit';

export const user = createSlice({
name: 'user',
initialState: {
users:[],

},
reducers: {
setUsers:(state,action)=>{
state.users = action.payload.users;
},

},
});

export const {setUsers} = user.actions;

export default user.reducer;

Step 5: Here is step for setting redux and toast for notification inside _app.tsx

//_app.tsx

import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import { CssBaseline } from '@mui/material';
import { Provider } from 'react-redux';
import 'react-toastify/dist/ReactToastify.css';
import { store, persistor } from '../redux/store';
import { PersistGate } from 'redux-persist/integration/react';
import { ToastContainer } from 'react-toastify';
export default function App({ Component, pageProps }: AppProps) {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<Component {...pageProps} />
<ToastContainer />
</PersistGate>
</Provider>
);
}

Step 6: Create a chat folder inside pages and a file index.ts inside chat with these code :// index.ts

// index.ts
import { useState, useEffect, useRef } from 'react';
import Sidebar from '../../components/Sidebar';
import Profile from '@/components/Profile';
import { Button } from '@mui/material';
import { useRouter } from 'next/router';
import axios from 'axios';
import { setUsers } from '../../redux/slices/user';
import { store } from "../../redux/store";
import showAlert from '../../utils/swal';
import { useSelector } from 'react-redux';
import { io, Socket } from 'socket.io-client';
const Chat: React.FC = () => {
const { users } = useSelector((state: any) => state.user);
const [pro, setPro] = useState(false);
const [orginalToken, setOrginalToken] = useState<string | null>(null);
const [seller, setseller] = useState(false);
const [search, setSearch] = useState("");
const socket = useRef();
const router = useRouter();
const localToken = localStorage.getItem('token');
console.log(localToken, "token");
var token = localStorage.getItem('token');
if (!token) {
showAlert({
title: 'Oops…',
text: 'You are not login',
});
router.push("/")
}
useEffect(() => {
const fetchData = async () => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
setOrginalToken(token);
if (token) {
try {
const response = await axios.get('/api/user/getUser', {
headers: {
Authorization: `Bearer ${token}`
}
});
const users = response.data.user
setseller(response.data.user.seller)
store.dispatch(setUsers({ users }))
console.log(users);
} catch (error) {
console.error('Error fetching user data:', error);
}
}
}
};
fetchData();
}, []);
const renderProfile = () => {
if (pro) {
setPro(false)
} else {
setPro(true)
}
}
return (
<div>
<button className='round-image-chat-button'>
<img src={users?.profilePic} alt="User Avatar" className='round-image-chat' onClick={renderProfile} style={{ cursor: "pointer" }} />
<h3 className='round-image-chat'>{users?.firstname}{users?.lastname}</h3>
</button>
{pro ? <Profile setPro={setPro}/> : <Sidebar />}
{/* <Chat/> */}
</div>
);
};
export default Chat;

Step 7: Create a folder componets and inside folder create three file video.tsx ,Chat.tsx and Sidebar.tsx with these code :

//Sidebar.tsx

import React, { useState, useEffect } from 'react';
import Chat from '../components/Chat';
import { useSelector, useDispatch } from 'react-redux';
import allUser from '../utils/allUser';
import CircularProgress from '@mui/material/CircularProgress';
import getApproval from '@/utils/getApproval';
import { setAooroved } from '../redux/slices/approved';

type User = {
firstname: string;
lastname: string;
profilePic: string;

};

const Sidebar = () => {
const dispatch = useDispatch();
const { users } = useSelector((state: any) => state.user);
const [userData, setUserData] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const [filteredUsers, setFilteredUsers] = useState<User[]>(userData);
const [notFound, setNotFound] = useState(false);

const handleSearch = (query: string) => {
setSearchQuery(query);

if (query === '') {
setFilteredUsers(userData);
setNotFound(false);
} else {
const filtered = userData.filter((user) => {
const fullName = `${user.firstname} ${user.lastname}`;
return fullName.toLowerCase().includes(query.toLowerCase());
});

setFilteredUsers(filtered);

if (filtered.length === 0) {
setNotFound(true);
} else {
setNotFound(false);
}
}
};

useEffect(() => {
allUser()
.then((data) => {
setUserData(data);
setFilteredUsers(data);
})
.catch((error) => {
console.error("An error occurred:", error);
});
}, []);

useEffect(() => {
const token = localStorage.getItem('token');

const getApprovalData = async () => {
const approvalData = await getApproval(token);
console.log(approvalData, "approvalData");
dispatch(setAooroved(approvalData));
};

getApprovalData();
}, [dispatch]);

const handleUserClick = (user: User) => {
setSelectedUser(user);
};

return (
<>
<div className="sidebar">
<input
className="searchbox"
placeholder="Search..."
value={searchQuery}
onChange={(e) => {
handleSearch(e.target.value);
}}
/>
</div>
<div className="sidebar">
<div className="sidebar-users">
{userData.length === 0 ? (
<CircularProgress disableShrink />
) : notFound ? (
<p style={{ color: "black" }}>No users found.</p>
) : (
filteredUsers.map((user, index) => (
<div className="user" key={index} onClick={() => handleUserClick(user)}>
<div className="user-image">
<img
className="img"
src={user.profilePic}
alt={`${user.firstname} ${user.lastname}`}
/>
</div>
<div className="user-info">
<button className="user-button" onClick={() => handleUserClick(user)}>
<span>
{user.firstname} {user.lastname}
</span>
</button>
</div>
</div>
))
)}
</div>
</div>

<div className="">
{selectedUser && <Chat selectedUser={selectedUser} />}
</div>
</>
);
};

export default Sidebar;

//Chat.tsx
import React, { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import axios from 'axios';
import { useSelector } from 'react-redux';
import getApproval from "@/utils/getApproval";
import sendApproval from "@/utils/sendApproval";
import { toast } from 'react-toastify';
import VideocamIcon from '@mui/icons-material/Videocam';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import '@livekit/components-styles';
import {
LiveKitRoom,
VideoConference,
GridLayout,
ParticipantTile,
} from '@livekit/components-react';
import Video from './video';
interface Message {
text: string;
type: 'sent' | 'received';
}

interface Messagess {
createdAt: string;
receiver: string;
sender: string;
status: string;
updatedAt: string;
__v: number;
_id: string;
}

const Chat = (selectedUser: any) => {
const { users } = useSelector((state: any) => state.user);
const { approve } = useSelector((state: any) => state.approved);
const [messages, setMessages] = useState<Message[]>([]);
const [messageText, setMessageText] = useState('');
const [sender, setSender] = useState(false);
const [isSendButtonEnabled, setIsSendButtonEnabled] = useState(false);
const [roomMessages, setRoomMessages] = useState<Message[]>([]);
const [receivedMessages, setReceivedMessages] = useState<Message[]>([]);
const [receive_message, setReceiveMessage] = useState('');
const token = localStorage.getItem('token');
const room = "quickstart-room";
const name = "quickstart-user";
const [tokens, setTokens] = useState("");
const [videocall, setVideoCall] = useState(false);

const toggleVideoCallCopmonent = () => {
setVideoCall(!videocall);
};
// Define the room name
const roomName = "YourRoomName"; // Replace with your desired room name
console.log(selectedUser?.selectedUser?._id, "selelelelle");
const matchedMessage = approve.allApp.find(
(message: Messagess) =>
message.receiver === selectedUser?.selectedUser?.email
);

const sendApp = approve.sendApproved.find(
(message: Messagess) =>
message.sender === selectedUser?.selectedUser?.email
);

const socket: Socket = io();
useEffect(() => {
socket?.emit('addUser', users?._id);
socket?.on('getUsers', users => {
console.log('activeUsers :>> ', users);
})
socket?.on('getMessage', data => {
console.log(data, "getMessagegetMessage");
})
}, [])
useEffect(()=>{
socket?.on('getMessage', data => {
console.log(data, "get98888888getMessage");
})
},[socket])

async function socketInitializer() {
await fetch("/api/chat/socket");

socket.on("getMessage", (data) => {
console.log(data, "this is received message");
const newMessage: Message = { text: data.messageText, type: 'received' };
setMessages([...messages, newMessage]);
socket.on("join-room", (room, username) => {
//@ts-ignore
socket.join(room);
});
// Check if the received message is in the desired room
if (data.room === roomName) {
const newReceivedMessage: Message = { text: data.messageText, type: 'received' };
setReceivedMessages((prevMessages) => [...prevMessages, newReceivedMessage]);
}
});
}
const [abc,setabc]=useState(false);
console.log(abc,"abccc");
const sonu=abc?"gb":null
const aa=abc?"raam":abc===false?"bcd":"raam"


useEffect(() => {
socketInitializer();

socket.emit("join-room", roomName, users.email);
}, []);

const action = async (e: string) => {
console.log(e, "eeeeeeeeeeeeee");
const send = await sendApproval(token, selectedUser?.selectedUser?.email, e);
getApproval(token);
}


const handleSendMessage = async () => {
if (messageText) {
const newMessage: Message = { text: messageText, type: 'sent' };
setMessages([...messages, newMessage]);
// Remove these lines to keep the button enabled after sending a message
// setMessageText('');
setIsSendButtonEnabled(false);
sendmessage();
}
};

const _id = users?._id;
const receiver = selectedUser?.selectedUser?._id;


const sendmessage = async () => {
// Include the room name when emitting the message
socket.emit("sendmessage", { messageText, receiver, _id });
console.log("object89101");
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value;
setMessageText(text);
setIsSendButtonEnabled(!!text);
}
console.log(roomMessages, "roomMessagesroomMessages", receivedMessages);

return (
<div className='chatt'>
<div className="chat-container">
<div className="chat-header">
<img src={selectedUser?.selectedUser?.profilePic} alt="Receiver Avatar" className="receiver-avatar" />
<h2>{selectedUser?.selectedUser?.firstname} {selectedUser?.selectedUser?.lastname}</h2>
<div className="videocall">
{users.email !== selectedUser?.selectedUser?.email ? (
<>
<Tooltip title="Video call">
<IconButton onClick={toggleVideoCallCopmonent}>
<VideocamIcon className="videocall" fontSize="large" style={{ color: 'white' }} />
</IconButton>
</Tooltip>
{videocall && <Video id={selectedUser?.selectedUser?._id} />}
</>
) : null}
</div>
<div className='moreheader'>
<Tooltip title="More">
<IconButton>
<MoreVertIcon className='moreheader' style={{ color: 'white' }} />
</IconButton>
</Tooltip>
</div>
</div>
<div className="chat-messages">
<div className="message-scroll">
{messages.map((message, index) => (
<div
key={index}
className={`message ${message.type === 'sent' ? 'sent-message allsent': 'received-message' }`}
>
{message.text}
</div>
))}
{receivedMessages.map((message, index) => (
<div
key={index}
className={`message received-message`}
>
{message.text}
</div>
))}
</div>
</div>
{!sendApp && users.email !== selectedUser?.selectedUser?.email && !matchedMessage ? (
<button className="actionButton" onClick={(e) => action("Send")}>Send Request</button>
) : !sendApp && users.email !== selectedUser?.selectedUser?.email && matchedMessage.status === "pending" ? (
<h3 className="disableText">Pending Request</h3>
) : !sendApp && users.email !== selectedUser?.selectedUser?.email && matchedMessage.status === "Send" ? (
<h3 className="disableText">Already Send</h3>
) : !sendApp && users.email !== selectedUser?.selectedUser?.email && matchedMessage.status === "Approved" ? (
<div className="message-input">
<input
type="text"
placeholder="Type a message..."
value={messageText}
className='inputmessage'
onChange={handleInputChange}
/>
<button onClick={handleSendMessage} disabled={!isSendButtonEnabled} className="button-send">
Send
</button>
</div>
) : null}

{sendApp && !matchedMessage && users.email !== selectedUser?.selectedUser?.email && sendApp.status === "Send" ? (
<button className="actionButton" onClick={(e) => action("Approved")}>Accept Request</button>
) : sendApp && users.email !== selectedUser?.selectedUser?.email && !matchedMessage && sendApp.status === "Approved" ? (
<div className="message-input">
<input
type="text"
placeholder="Type a message..."
value={messageText}
className='inputmessage'
onChange={handleInputChange}
/>
<button onClick={handleSendMessage} disabled={!isSendButtonEnabled} className="button-send">
Send
</button>
</div>
) : null}
{users.email === selectedUser?.selectedUser?.email && (
<div className="message-input">
<input
type="text"
placeholder="Type a message..."
value={messageText}
className='inputmessage'
onChange={(e: any) => {
setMessageText(e.target.value);
handleInputChange(e);
}}
/>
<button onClick={handleSendMessage} className="button-send">
Send
</button>
</div>
)}
</div>
</div>
);
};

export default Chat;
//video.tsx
import "@livekit/components-styles";
import {
LiveKitRoom,
VideoConference,
GridLayout,
ParticipantTile,VideoTrack,
useTracks,
RoomAudioRenderer,
ControlBar,
} from "@livekit/components-react";
import { useEffect, useState } from "react";
import axios from "axios";
import { useSelector } from "react-redux";

const Video = (id:any) => {
const { users } = useSelector((state: any) => state.user);
console.log(users, "usuususus",id.id);
const url = process.env.NEXT_PUBLIC_LIVEKIT_URL;
// TODO: get user input for room and name
const room = "quickstart-room";
const name = "65242a848a13c6b0501a30b0";
const videoss = room;
const [token, setToken] = useState("");
useEffect(() => {
(async () => {
try {
const resp = await axios.get(
`/api/chat/video?room=${room}&username=${id.id}`
);
setToken(resp.data.token);
console.log(resp, "respresp");
} catch (e) {
console.error(e);
}
})();
}, []);
return (
<LiveKitRoom
video={true}
audio={true}
token={token}
connectOptions={{ autoSubscribe: false }}
serverUrl={process.env.NEXT_PUBLIC_LIVEKIT_URL}
data-lk-theme="default"
style={{ height: '100vh' }}
>
<VideoConference />
<RoomAudioRenderer />
</LiveKitRoom>
);
}
function MyVideoConference() {
// `useTracks` returns all camera and screen share tracks. If a user
// joins without a published camera track, a placeholder track is returned.
const tracks = useTracks(
[
//@ts-ignore
// { source: Track.Source.Camera, withPlaceholder: true },
//@ts-ignore
// { source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ onlySubscribed: false }
);
return (
<>
<GridLayout
tracks={tracks}
style={{ height: "calc(100vh - var(--lk-control-bar-height))" }}
>
<ParticipantTile />
</GridLayout>
{/* <LiveKitRoom serverUrl={url} connect={true} token={token}> */}
{/* <VideoConference /> */}
{/**@ts-ignore */}
{/* <VideoTrack trackRef={tracks} /> */}
{/* </LiveKitRoom> */}
</>
);
}

export default Video;

Step 8: Create .env file with these details ,livekit is free to use for developers:

MONGODB_URI=
JWT_SECRET=
NEXT_PUBLIC_CLOUDINARY=
NEXT_PUBLIC_SOCKET_URL=http://localhost:3000/api/
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
NEXT_PUBLIC_LIVEKIT_URL=

Step 9: Create two files inside api/chat with [[…video]].ts , socket.js name with these code:

// [[...video]].ts
import { AccessToken } from "livekit-server-sdk";
import { NextApiRequest, NextApiResponse } from 'next';

export default async function video( req: NextApiRequest,
res: NextApiResponse) {
try {

//@ts-ignore
const {video,room,username}=req.query
if (!room) {
return res.status(400).json({ error: 'room not found' });

} else if (!username) {
return res.status(400).json({ error: 'username not found' });
}

const apiKey = process.env.LIVEKIT_API_KEY;
const apiSecret = process.env.LIVEKIT_API_SECRET;
const wsUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL;
if (!apiKey || !apiSecret || !wsUrl) {
return res.status(400).json({ error: ' found' });
}
//@ts-ignore
const at = new AccessToken(apiKey, apiSecret, { identity: username });
console.log(at,"atttttttttt");
//@ts-ignore
// const aw= at.addGrant({ room, roomJoin: true, canPublish: true, canSubscribe: true });
const aw=at.addGrant({ roomJoin: true, room });
return res.json({ message:"token: at.toJwt()" });
} catch (error) {
res.status(500).json({ error: 'An internal server error occurred' });

}
}
//socket.js
import { Server } from "socket.io";
import Chat from "../../../models/Message";
import User from "../../../models/Users"
export default function SocketHandler(req, res) {
if (res.socket.server.io) {
console.log("Already set up");
res.end();
return;
}
let allusers = [];

const io = new Server(res.socket.server);
res.socket.server.io = io;

io.on("connection", (socket) => {
console.log("Client connected", socket.id);
socket.on('addUser', userId => {
const isUserExist = allusers.find(user => user.userId === userId);
if (!isUserExist) {
const user = { userId, socketId: socket.id };
allusers.push(user);
io.emit('getUsers', allusers);
}
});
// console.log(allusers,"usersusers");
socket.on("sendmessage", async ({ messageText, receiver, _id }) => {
console.log("Received message:", messageText, receiver, _id);
try {
console.log("Received message:", messageText, receiver, _id);
// console.log(dmessageText, receiver, _id ,"datat.reciiieiei");
const rec = receiver;
const recer = allusers.find(user =>
// console.log(user,"userrr"))
user.userId === rec);
const sender = allusers.find(user => user.userId === _id);
const user = await User.findById(_id);
const re = await User.findById(receiver)
console.log(user, "jzshfkjsdhfkjshdfkjhskdj", re);
if (recer) {
console.log("1111");
io.to(receiver.socketId).to(recer.socketId).emit('getMessage', {
_id,
messageText,
receiver,
// allusers: { id: user._id, fullName: user.fullName, email: user.email }
});
const newMessage = new Chat({
receiver: re.email,
sender: user.email,
message: messageText,
});

await newMessage.save();

} else {
console.log("11122221");

io.to(sender.socketId).emit('getMessage', {
_id,
messageText,

receiver,
// user: { id: user._id, fullName: user.fullName, email: user.email }
});
}
console.log(recer, "receiverreceiver");

} catch (error) {
console.error("Error saving message:", error);
}
});

socket.join(socket.id); // Create a room with the socket's ID

socket.on("private-message", (targetSocketId, data) => {
try {
// Use the targetSocketId to send a private message to the specific client
io.to(targetSocketId).emit("receive-private-message", data);

console.log("Sent a private message to", targetSocketId, ":", data);
} catch (error) {
console.error("Error sending private message:", error);
}
});
socket.on('disconnect', () => {
allusers = allusers.filter(user => user.socketId !== socket.id);
io.emit('getallusers', allusers);
});
});

console.log("Setting up socket");
res.end();
}

Step 10: Now start server with and if 3000 port will not taken then go to the http://localhost:3000 and see your project is running :

npm run dev

For test this ,open multiple windows and also login with different user and you can see the messages are getting without any api call.

In this project people have to take approval form user to send message.

This is the basic setup of redux ,socketio and livekit using nextjs 13 ,currently this project is under developement ,so may be some changes will push to the codebase in future ,this is the CODEBASE of project.

Happy Coding

--

--

Abhishek Mishra

Full stack developer 👨🏽‍💻 | MERN STACK | Rust🦀