Kuasai real-time chat application development. Pelajari WebSocket implementation, JWT authentication, Google SSO, MongoDB untuk messages, PostgreSQL untuk structured data, dan build complete chat app dengan NestJS backend dan TanStack Start frontend menggunakan best practices.

Real-time communication adalah essential untuk modern applications. Users expect instant message delivery, live notifications, dan seamless collaboration. Membangun chat application memerlukan handling concurrent connections, managing state, ensuring security, dan scaling ke thousands dari users.
Dalam artikel ini, kita akan build production-ready real-time chat application dengan NestJS backend dan TanStack Start frontend. Kita akan implement WebSocket untuk real-time messaging, JWT authentication dengan Google SSO, MongoDB untuk chat messages, dan PostgreSQL untuk structured data.
Chat application terdiri dari:
npm i -g @nestjs/cli
nest new chat-backend
cd chat-backend
npm install @nestjs/websockets @nestjs/platform-socket.io socket.io
npm install @nestjs/typeorm typeorm pg
npm install @nestjs/mongoose mongoose
npm install @nestjs/jwt @nestjs/passport passport passport-jwt
npm install @nestjs/config dotenv
npm install google-auth-library
npm install class-validator class-transformerimport { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MongooseModule } from '@nestjs/mongoose';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('DB_HOST'),
port: configService.get('DB_PORT'),
username: configService.get('DB_USER'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_NAME'),
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
synchronize: true,
}),
}),
MongooseModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
uri: configService.get('MONGODB_URI'),
}),
}),
],
})
export class DatabaseModule {}import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { ChatRoom } from './chat-room.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
username: string;
@Column({ nullable: true })
password: string;
@Column({ nullable: true })
googleId: string;
@Column({ nullable: true })
avatar: string;
@Column({ default: true })
isActive: boolean;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
@OneToMany(() => ChatRoom, (room) => room.creator)
createdRooms: ChatRoom[];
}import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, ManyToMany, JoinTable } from 'typeorm';
import { User } from './user.entity';
@Entity('chat_rooms')
export class ChatRoom {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ nullable: true })
description: string;
@ManyToOne(() => User, (user) => user.createdRooms)
creator: User;
@ManyToMany(() => User)
@JoinTable()
members: User[];
@Column({ default: false })
isPrivate: boolean;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
updatedAt: Date;
}import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, Types } from 'mongoose';
@Schema({ timestamps: true })
export class Message extends Document {
@Prop({ required: true })
roomId: string;
@Prop({ required: true })
userId: string;
@Prop({ required: true })
username: string;
@Prop({ required: true })
content: string;
@Prop({ nullable: true })
avatar: string;
@Prop({ default: 'text', enum: ['text', 'image', 'file'] })
type: string;
@Prop({ nullable: true })
fileUrl: string;
@Prop({ default: false })
isEdited: boolean;
@Prop({ nullable: true })
editedAt: Date;
createdAt: Date;
updatedAt: Date;
}
export const MessageSchema = SchemaFactory.createForClass(Message);import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entities/user.entity';
import * as bcrypt from 'bcryptjs';
import { OAuth2Client } from 'google-auth-library';
@Injectable()
export class AuthService {
private googleClient: OAuth2Client;
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
private jwtService: JwtService,
) {
this.googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
}
async register(email: string, username: string, password: string) {
const existingUser = await this.userRepository.findOne({ where: { email } });
if (existingUser) {
throw new UnauthorizedException('Email already registered');
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = this.userRepository.create({
email,
username,
password: hashedPassword,
});
await this.userRepository.save(user);
return this.generateToken(user);
}
async login(email: string, password: string) {
const user = await this.userRepository.findOne({ where: { email } });
if (!user || !user.password) {
throw new UnauthorizedException('Invalid credentials');
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
return this.generateToken(user);
}
async googleLogin(token: string) {
try {
const ticket = await this.googleClient.verifyIdToken({
idToken: token,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
let user = await this.userRepository.findOne({
where: { googleId: payload.sub },
});
if (!user) {
user = this.userRepository.create({
email: payload.email,
username: payload.name,
googleId: payload.sub,
avatar: payload.picture,
});
await this.userRepository.save(user);
}
return this.generateToken(user);
} catch (error) {
throw new UnauthorizedException('Invalid Google token');
}
}
private generateToken(user: User) {
const payload = { sub: user.id, email: user.email, username: user.username };
return {
access_token: this.jwtService.sign(payload),
user: { id: user.id, email: user.email, username: user.username },
};
}
async validateToken(token: string) {
try {
return this.jwtService.verify(token);
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
}import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { ChatService } from './chat.service';
import { AuthService } from '../auth/auth.service';
@WebSocketGateway({
cors: { origin: process.env.FRONTEND_URL },
namespace: '/chat',
})
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private userSockets = new Map<string, string>();
constructor(
private chatService: ChatService,
private authService: AuthService,
) {}
async handleConnection(client: Socket) {
try {
const token = client.handshake.auth.token;
const payload = await this.authService.validateToken(token);
client.data.userId = payload.sub;
client.data.username = payload.username;
this.userSockets.set(payload.sub, client.id);
this.server.emit('user-online', {
userId: payload.sub,
username: payload.username,
});
} catch (error) {
client.disconnect();
}
}
handleDisconnect(client: Socket) {
const userId = client.data.userId;
if (userId) {
this.userSockets.delete(userId);
this.server.emit('user-offline', { userId });
}
}
@SubscribeMessage('join-room')
async handleJoinRoom(client: Socket, data: { roomId: string }) {
client.join(data.roomId);
const messages = await this.chatService.getMessages(data.roomId, 0, 50);
client.emit('room-messages', messages);
this.server.to(data.roomId).emit('user-joined', {
userId: client.data.userId,
username: client.data.username,
});
}
@SubscribeMessage('send-message')
async handleSendMessage(
client: Socket,
data: { roomId: string; content: string },
) {
const message = await this.chatService.createMessage({
roomId: data.roomId,
userId: client.data.userId,
username: client.data.username,
content: data.content,
});
this.server.to(data.roomId).emit('new-message', message);
}
@SubscribeMessage('typing')
handleTyping(client: Socket, data: { roomId: string }) {
this.server.to(data.roomId).emit('user-typing', {
userId: client.data.userId,
username: client.data.username,
});
}
@SubscribeMessage('stop-typing')
handleStopTyping(client: Socket, data: { roomId: string }) {
this.server.to(data.roomId).emit('user-stop-typing', {
userId: client.data.userId,
});
}
}import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Message } from '../schemas/message.schema';
import { ChatRoom } from '../entities/chat-room.entity';
@Injectable()
export class ChatService {
constructor(
@InjectModel(Message.name)
private messageModel: Model<Message>,
@InjectRepository(ChatRoom)
private chatRoomRepository: Repository<ChatRoom>,
) {}
async createMessage(data: {
roomId: string;
userId: string;
username: string;
content: string;
}) {
const message = new this.messageModel(data);
return message.save();
}
async getMessages(roomId: string, skip: number, limit: number) {
return this.messageModel
.find({ roomId })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean();
}
async createRoom(data: {
name: string;
description?: string;
creatorId: string;
isPrivate: boolean;
}) {
const room = this.chatRoomRepository.create({
name: data.name,
description: data.description,
creator: { id: data.creatorId } as any,
isPrivate: data.isPrivate,
});
return this.chatRoomRepository.save(room);
}
async getRooms(userId: string) {
return this.chatRoomRepository
.createQueryBuilder('room')
.leftJoinAndSelect('room.members', 'members')
.where('room.creatorId = :userId OR members.id = :userId', { userId })
.getMany();
}
async joinRoom(roomId: string, userId: string) {
const room = await this.chatRoomRepository.findOne({
where: { id: roomId },
relations: ['members'],
});
if (!room.members.find((m) => m.id === userId)) {
room.members.push({ id: userId } as any);
await this.chatRoomRepository.save(room);
}
return room;
}
}version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: chat_user
POSTGRES_PASSWORD: password
POSTGRES_DB: chat_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
mongodb:
image: mongo:7.0
ports:
- "27017:27017"
volumes:
- mongodb_data:/data/db
backend:
build: ./chat-backend
ports:
- "3000:3000"
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: chat_user
DB_PASSWORD: password
DB_NAME: chat_db
MONGODB_URI: mongodb://mongodb:27017/chat
JWT_SECRET: your-secret-key
GOOGLE_CLIENT_ID: your-google-client-id
FRONTEND_URL: http://localhost:5173
depends_on:
- postgres
- mongodb
volumes:
postgres_data:
mongodb_data:npm create @tanstack/start@latest chat-frontend
cd chat-frontend
npm install socket.io-client zustand axios
npm install @react-oauth/google
npm install zustandimport { create } from 'zustand';
import { io, Socket } from 'socket.io-client';
interface Message {
_id: string;
roomId: string;
userId: string;
username: string;
content: string;
createdAt: Date;
}
interface ChatRoom {
id: string;
name: string;
description?: string;
members: any[];
}
interface ChatStore {
socket: Socket | null;
user: any | null;
rooms: ChatRoom[];
currentRoom: ChatRoom | null;
messages: Message[];
typingUsers: Set<string>;
onlineUsers: Set<string>;
setUser: (user: any) => void;
setRooms: (rooms: ChatRoom[]) => void;
setCurrentRoom: (room: ChatRoom) => void;
addMessage: (message: Message) => void;
setMessages: (messages: Message[]) => void;
addTypingUser: (userId: string, username: string) => void;
removeTypingUser: (userId: string) => void;
addOnlineUser: (userId: string) => void;
removeOnlineUser: (userId: string) => void;
initSocket: (token: string) => void;
joinRoom: (roomId: string) => void;
sendMessage: (content: string) => void;
createRoom: (name: string, description?: string) => void;
}
export const useChatStore = create<ChatStore>((set, get) => ({
socket: null,
user: null,
rooms: [],
currentRoom: null,
messages: [],
typingUsers: new Set(),
onlineUsers: new Set(),
setUser: (user) => set({ user }),
setRooms: (rooms) => set({ rooms }),
setCurrentRoom: (room) => set({ currentRoom: room }),
addMessage: (message) =>
set((state) => ({ messages: [...state.messages, message] })),
setMessages: (messages) => set({ messages }),
addTypingUser: (userId, username) =>
set((state) => ({
typingUsers: new Set([...state.typingUsers, `${userId}:${username}`]),
})),
removeTypingUser: (userId) =>
set((state) => {
const newSet = new Set(state.typingUsers);
newSet.forEach((item) => {
if (item.startsWith(userId)) newSet.delete(item);
});
return { typingUsers: newSet };
}),
addOnlineUser: (userId) =>
set((state) => ({ onlineUsers: new Set([...state.onlineUsers, userId]) })),
removeOnlineUser: (userId) =>
set((state) => {
const newSet = new Set(state.onlineUsers);
newSet.delete(userId);
return { onlineUsers: newSet };
}),
initSocket: (token) => {
const socket = io(import.meta.env.VITE_API_URL, {
auth: { token },
namespace: '/chat',
});
socket.on('new-message', (message) => {
get().addMessage(message);
});
socket.on('user-typing', ({ userId, username }) => {
get().addTypingUser(userId, username);
});
socket.on('user-stop-typing', ({ userId }) => {
get().removeTypingUser(userId);
});
socket.on('user-online', ({ userId }) => {
get().addOnlineUser(userId);
});
socket.on('user-offline', ({ userId }) => {
get().removeOnlineUser(userId);
});
socket.on('room-messages', (messages) => {
get().setMessages(messages);
});
set({ socket });
},
joinRoom: (roomId) => {
const socket = get().socket;
if (socket) {
socket.emit('join-room', { roomId });
}
},
sendMessage: (content) => {
const socket = get().socket;
const currentRoom = get().currentRoom;
if (socket && currentRoom) {
socket.emit('send-message', {
roomId: currentRoom.id,
content,
});
}
},
createRoom: (name, description) => {
// API call untuk create room
},
}));import { createContext, useContext, useEffect, useState } from 'react';
import { useGoogleLogin } from '@react-oauth/google';
import axios from 'axios';
interface AuthContextType {
user: any | null;
token: string | null;
login: (email: string, password: string) => Promise<void>;
register: (email: string, username: string, password: string) => Promise<void>;
googleLogin: () => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState(null);
const [token, setToken] = useState(localStorage.getItem('token'));
const login = async (email: string, password: string) => {
const response = await axios.post(
`${import.meta.env.VITE_API_URL}/auth/login`,
{ email, password }
);
setToken(response.data.access_token);
setUser(response.data.user);
localStorage.setItem('token', response.data.access_token);
};
const register = async (
email: string,
username: string,
password: string
) => {
const response = await axios.post(
`${import.meta.env.VITE_API_URL}/auth/register`,
{ email, username, password }
);
setToken(response.data.access_token);
setUser(response.data.user);
localStorage.setItem('token', response.data.access_token);
};
const googleLogin = useGoogleLogin({
onSuccess: async (codeResponse) => {
const response = await axios.post(
`${import.meta.env.VITE_API_URL}/auth/google-login`,
{ token: codeResponse.access_token }
);
setToken(response.data.access_token);
setUser(response.data.user);
localStorage.setItem('token', response.data.access_token);
},
});
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('token');
};
return (
<AuthContext.Provider value={{ user, token, login, register, googleLogin, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};import { useEffect, useState } from 'react';
import { useChatStore } from '../store/chatStore';
import { useAuth } from './AuthProvider';
export const ChatRoom = () => {
const { user } = useAuth();
const {
currentRoom,
messages,
typingUsers,
sendMessage,
joinRoom,
socket,
} = useChatStore();
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
useEffect(() => {
if (currentRoom && socket) {
joinRoom(currentRoom.id);
}
}, [currentRoom, socket]);
const handleSendMessage = () => {
if (input.trim()) {
sendMessage(input);
setInput('');
setIsTyping(false);
if (socket) {
socket.emit('stop-typing', { roomId: currentRoom?.id });
}
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
if (!isTyping && socket) {
setIsTyping(true);
socket.emit('typing', { roomId: currentRoom?.id });
}
};
if (!currentRoom) {
return <div>Select room untuk start chatting</div>;
}
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4">
{messages.map((msg) => (
<div
key={msg._id}
className={`mb-4 ${msg.userId === user?.id ? 'text-right' : ''}`}
>
<div className="font-semibold text-sm">{msg.username}</div>
<div
className={`inline-block p-2 rounded ${
msg.userId === user?.id
? 'bg-blue-500 text-white'
: 'bg-gray-200'
}`}
>
{msg.content}
</div>
<div className="text-xs text-gray-500">
{new Date(msg.createdAt).toLocaleTimeString()}
</div>
</div>
))}
{typingUsers.size > 0 && (
<div className="text-sm text-gray-500 italic">
{Array.from(typingUsers)
.map((u) => u.split(':')[1])
.join(', ')} is typing...
</div>
)}
</div>
<div className="border-t p-4 flex gap-2">
<input
type="text"
value={input}
onChange={handleInputChange}
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
placeholder="Type message..."
className="flex-1 border rounded px-3 py-2"
/>
<button
onClick={handleSendMessage}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
Send
</button>
</div>
</div>
);
};// ✅ Validate JWT tokens
// ✅ Use HTTPS dalam production
// ✅ Implement rate limiting
// ✅ Sanitize user input
// ✅ Use secure WebSocket (WSS)
// ✅ Implement CORS properly// ✅ Paginate messages
// ✅ Use message caching
// ✅ Implement connection pooling
// ✅ Use compression
// ✅ Optimize database queries
// ✅ Implement lazy loading// ✅ Use Redis untuk session management
// ✅ Implement horizontal scaling
// ✅ Use message queues (RabbitMQ, Kafka)
// ✅ Separate read/write databases
// ✅ Implement load balancing
// ✅ Use CDN untuk static assets// ✅ Implement typing indicators
// ✅ Show online/offline status
// ✅ Implement message read receipts
// ✅ Handle connection reconnection
// ✅ Implement message delivery confirmation
// ✅ Handle network failures gracefully// ✅ Index frequently queried fields
// ✅ Use TTL untuk temporary data
// ✅ Archive old messages
// ✅ Implement data retention policies
// ✅ Use transactions untuk consistency
// ✅ Monitor database performance// ❌ Wrong - no reconnection logic
socket.on('disconnect', () => {
console.log('Disconnected');
});
// ✅ Correct - implement reconnection
socket.on('disconnect', () => {
setTimeout(() => {
socket.connect();
}, 5000);
});// ❌ Wrong - storing password
localStorage.setItem('password', password);
// ✅ Correct - hanya store token
localStorage.setItem('token', token);// ❌ Wrong - no validation
socket.on('send-message', (data) => {
saveMessage(data);
});
// ✅ Correct - validate input
socket.on('send-message', (data) => {
if (data.content && data.content.trim().length > 0) {
saveMessage(data);
}
});// ❌ Wrong - load semua messages
const messages = await Message.find({ roomId });
// ✅ Correct - paginate messages
const messages = await Message.find({ roomId })
.sort({ createdAt: -1 })
.limit(50)
.skip(page * 50);// ❌ Wrong - race condition
user.messageCount++;
await user.save();
// ✅ Correct - atomic operation
await User.updateOne(
{ id: userId },
{ $inc: { messageCount: 1 } }
);# Build images
docker-compose build
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose downMembangun real-time chat application memerlukan careful consideration dari security, performance, dan scalability. Dengan following best practices dan using right technologies, Anda bisa build robust chat system yang handle thousands dari concurrent users.
Key takeaways:
Next steps:
Real-time chat applications adalah complex tapi rewarding untuk build. Master concepts ini, dan Anda akan bisa build scalable, secure communication platforms.