A real-time group chat application built from scratch with a security-first mindset.
Full-stack TypeScript · Socket.IO · Prisma ORM · React 19 · Framer Motion
- Overview
- Architecture
- Key Features
- Security Hardening
- Engineering Decisions
- Tech Stack
- Project Structure
- Getting Started
- Docker
- Testing
- Load Testing
- Environment Variables
- API Reference
- Socket.IO Events
- Database Schema
- Demo
ChatFlow is a real-time group messaging platform built without BaaS platforms or shortcuts. Every layer — from password hashing to WebSocket room management — is hand-implemented with intent and security in mind.
The codebase applies patterns common to production systems: structured logging, graceful shutdown, cursor-based pagination, role-based access control, tiered rate limiting, and a fully typed Socket.IO event contract between client and server. CI, tests, Docker, and load testing are in place to back those claims.
┌────────────────────────────────────────────────────────────────┐
│ React 19 (Vite) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ AuthPage │ │ Sidebar │ │GroupList │ │ ChatPanel │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────────┘ │
│ │ │ │ │ │
│ └──────────────┼─────────────┼──────────────┘ │
│ │ │ │
│ ┌───────▼─────────────▼──────────┐ │
│ │ AuthContext · API Client │ │
│ │ Socket.IO Client Singleton │ │
│ └───────┬─────────────┬──────────┘ │
└───────────────────────┼─────────────┼──────────────────────────┘
REST │ │ WebSocket
▼ ▼
┌───────────────────────────────────────────────────────────────────┐
│ Express 5 + Socket.IO │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Middleware Pipeline │ │
│ │ Helmet → CORS → JSON Parser → Global Rate Limiter │ │
│ └──────────────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌──────────┐ ┌──────────┐ ┌─▼────────┐ ┌──────────────┐ │
│ │ /auth │ │ /users │ │ /groups │ │ /messages │ │
│ │ register │ │ me │ │ CRUD │ │ send/query │ │
│ │ login │ │ all(adm) │ │ members │ │ pagination │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────────┘ │
│ │ │ │ │ │
│ ┌────▼──────────────▼─────────────▼──────────────▼──────────┐ │
│ │ Service Layer (business logic + authorization checks) │ │
│ └────┬──────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────▼──────────────────────────────────────────────────────┐ │
│ │ Prisma Client (pg adapter, singleton, health check) │ │
│ └────┬──────────────────────────────────────────────────────┘ │
└────────┼─────────────────────────────────────────────────────────┘
│
▼
┌──────────┐
│PostgreSQL│
│(Supabase)│
└──────────┘
- WebSocket-powered chat via Socket.IO with automatic reconnection and transport fallback (WebSocket → polling).
- Dual delivery path: Messages are persisted via REST API and simultaneously broadcast to all group members through Socket.IO rooms.
- Typing indicators — lightweight fire-and-forget broadcasts with automatic timeout after 2 seconds of inactivity.
- Online/offline presence — multi-tab aware. A user only appears "offline" when all their browser tabs disconnect.
- Create, update, and manage groups with role-based access control (
ADMIN,MODERATOR,MEMBER). - Admin-gated operations: Only group admins can add/remove members or update group details.
- Orphan protection: The last admin of a group cannot be removed — prevents headless groups.
- Socket eviction: When a member is removed via REST, their socket is automatically evicted from the room in real time.
- Auto-join on connect: When a socket authenticates, it automatically joins all group rooms the user is a member of.
- JWT-based auth with configurable expiration (
7ddefault). - scrypt password hashing using Node.js native
crypto— no bcrypt dependency needed. - Timing-safe comparison via
timingSafeEqualto prevent timing attacks on password verification. - Session restoration — tokens are persisted in
localStorageand validated against/users/meon app mount.
- Tag system with
connectOrCreate— tags are created on-the-fly if they don't exist, or linked if they do. - Up to 10 tags per message, each validated and length-constrained.
- Tags are returned with every message query and displayed inline in the chat UI.
- Efficient cursor-based pagination for message history instead of offset-based.
- Configurable page size (1–100, default 50), newest-first ordering.
- Returns a
nextCursorfor seamless infinite scroll integration.
This project went through two full security audits. Here's what's in place:
| Layer | Protection | Implementation |
|---|---|---|
| Transport | HTTPS-ready, CORS origin whitelist | cors() with explicit CORS_ORIGINS from env |
| Headers | Security headers on every response | helmet() — CSP, HSTS, X-Frame-Options, etc. |
| Input | Every payload validated before processing | Zod schemas on REST body/params/query AND Socket.IO payloads |
| Auth | JWT with minimum 32-char secret | Zod-enforced JWT_SECRET length at boot |
| Passwords | scrypt + 32-byte random salt + timing-safe compare | node:crypto — zero external deps |
| Rate Limiting | Tiered: Global (100/15m), Auth (10/15m), Message (30/1m) | express-rate-limit with draft-8 standard headers |
| Authorization | Per-operation membership + role checks | assertMembership() / assertGroupAdmin() guards |
| Socket Auth | JWT verified on every socket handshake | Socket.IO middleware — no event handlers register without valid token |
| Error Masking | Stack traces + internal messages hidden in production | Global error handler strips details when NODE_ENV=production |
| Body Size | Request body capped at 10KB | express.json({ limit: '10kb' }) |
| Proxy Trust | Configurable proxy depth | TRUST_PROXY env var, only active in production |
| Prisma Errors | Mapped to safe HTTP responses | P2002 → 409, P2025 → 404, prevents ORM leak |
| Socket Spoofing | Room-joined check before typing broadcast | socket.rooms.has(groupId) guard |
| Token Expiry | Distinct 401 with expired: true flag |
ExpiredTokenError class — enables client-side refresh flows |
These are the engineering decisions that set this project apart from a typical CRUD chat app:
Every socket event — client-to-server and server-to-client — is defined in a single types/socket.ts file with strict TypeScript generics. The Socket.IO server instance uses Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>. This means every emit() and on() call is compile-time checked — wrong event names or payload shapes are caught before runtime.
Most projects validate REST endpoints but skip socket payloads. We validate both. Every socket.on() handler runs the incoming payload through a Zod schema before processing. This prevents malformed or malicious data from reaching the service layer through either channel.
The server refuses to start if any environment variable is missing or malformed. config/env.ts parses process.env through a Zod schema on boot, producing a fully typed env object. No more process.env.PORT scattered across the codebase — every access is validated and typed.
Online/offline status is tracked per-socket, not per-user. The server maintains a Map<userId, Set<socketId>>. A user:online event fires only on the first tab connection, and user:offline fires only when the last tab disconnects. This prevents the annoying flicker you see in most chat apps when users switch tabs.
Instead of pulling in bcrypt (which requires native compilation and has platform issues), we use Node.js built-in scrypt with a 32-byte random salt and 64-byte key length. Password comparison uses timingSafeEqual to prevent timing side-channel attacks. Zero external dependencies for auth crypto.
Routes are thin — they validate, call the service, and format the response. All business logic (authorization checks, database queries, error handling) lives in the service layer. Both REST routes and Socket.IO handlers reuse the same service functions (e.g., sendMessage(), assertMembership()), guaranteeing a single source of truth.
The server handles SIGINT and SIGTERM signals, closes the HTTP server, disconnects Prisma, and logs the shutdown. If graceful shutdown takes longer than 10 seconds, it force-exits to prevent zombie processes in container environments.
All logging goes through Pino — the fastest Node.js logger. Logs are structured JSON in production (machine-parseable) and human-readable in development. Every error includes the HTTP method, path, status code, and error object for easy debugging.
Instead of using Prisma's default connection handling, we use the @prisma/adapter-pg driver adapter with explicit SSL configuration. TLS is negotiated based on the connection host: hosted databases get a real handshake (with certificate validation enforced in production), while localhost and the Docker/CI service containers — which are not built with SSL support — skip it. The client is a global singleton with hot-reload safety.
The frontend uses a glassmorphism design system — translucent cards, backdrop blur, soft shadows, and gradient backgrounds. Every interaction is animated via Framer Motion: page transitions, tab indicators with layoutId, message entrance animations, typing indicator reveals, and micro-interactions on buttons (whileHover, whileTap).
React 19, Express 5, Prisma 7, Tailwind v4, TypeScript 6 (strict), and oxlint. Three versioned Prisma migrations tracking schema evolution.
| Technology | Version | Purpose |
|---|---|---|
| TypeScript | 6.0 (Strict) | End-to-end type safety |
| Express | 5.x | HTTP server + middleware pipeline |
| Socket.IO | 4.8 | Real-time bidirectional communication |
| Prisma | 7.8 | Type-safe ORM with PostgreSQL adapter |
| Zod | 4.x | Runtime schema validation |
| Pino | 10.x | Structured async logging |
| Helmet | 8.x | Security headers |
| jsonwebtoken | 9.x | JWT signing & verification |
| express-rate-limit | 8.x | Tiered rate limiting |
| Technology | Version | Purpose |
|---|---|---|
| React | 19 | Component library |
| Vite | 8.x | Build tool + dev server with HMR |
| Tailwind CSS | 4.x | Utility-first styling with custom theme |
| Framer Motion | 12.x | Animations and micro-interactions |
| Lucide React | 1.x | Icon library |
| Socket.IO Client | 4.8 | Real-time client |
| OxLint | 1.x | Linting |
| Technology | Purpose |
|---|---|
| PostgreSQL | Primary database (via Supabase) |
| Node.js | Runtime (ESM, --env-file flag) |
chat-fullstack/
│
├── backend/
│ ├── prisma/
│ │ ├── migrations/ # Prisma migration history
│ │ └── schema.prisma # Database models (User, Group, Message, Tag)
│ │
│ ├── src/
│ │ ├── config/
│ │ │ ├── env.ts # Zod-validated env config (fails fast on boot)
│ │ │ └── logger.ts # Pino structured async logger
│ │ │
│ │ ├── database/
│ │ │ └── prisma.ts # PrismaClient singleton + pg adapter + health check
│ │ │
│ │ ├── middleware/
│ │ │ ├── auth.ts # JWT authentication + role-based authorization
│ │ │ ├── errorHandler.ts # AppError hierarchy + global error handler
│ │ │ ├── rateLimiter.ts # Tiered rate limiting (global/auth/message)
│ │ │ └── validate.ts # Generic Zod validation middleware
│ │ │
│ │ ├── routes/
│ │ │ ├── auth.routes.ts # POST /register, /login
│ │ │ ├── chat.routes.ts # POST /messages, GET /messages
│ │ │ ├── room.routes.ts # CRUD /groups + member management
│ │ │ └── user.routes.ts # GET /users/me, GET /users (admin)
│ │ │
│ │ ├── schemas/
│ │ │ ├── auth.schema.ts # Registration (strong password policy), login
│ │ │ ├── chat.schema.ts # Message, group, member action schemas
│ │ │ └── socket.schema.ts # Socket.IO event payload schemas
│ │ │
│ │ ├── services/
│ │ │ ├── auth.service.ts # Register, login, scrypt hashing, JWT signing
│ │ │ ├── chat.service.ts # Message send + cursor-based pagination
│ │ │ ├── room.service.ts # Group CRUD + membership + admin assertions
│ │ │ └── user.service.ts # User profile lookup
│ │ │
│ │ ├── socket/
│ │ │ ├── handlers/
│ │ │ │ ├── chat.handler.ts # message:send → validate → persist → broadcast
│ │ │ │ ├── room.handler.ts # room:join / room:leave with membership guard
│ │ │ │ └── typing.handler.ts # typing:start / typing:stop (fire-and-forget)
│ │ │ └── index.ts # Socket.IO server setup, JWT middleware, presence
│ │ │
│ │ ├── types/
│ │ │ ├── api.ts # AuthUser, Role, Express augmentation
│ │ │ └── socket.ts # Fully typed event maps + payload interfaces
│ │ │
│ │ └── server.ts # Application entry point + graceful shutdown
│ │
│ ├── .env.example # Environment variable template
│ ├── prisma.config.ts # Prisma CLI configuration
│ ├── package.json
│ └── tsconfig.json # Strict mode, ESNext, NodeNext module
│
└── frontend/
├── public/ # Static assets
├── src/
│ ├── components/
│ │ ├── ChatPanel.tsx # Message display, input, typing indicator
│ │ ├── GroupList.tsx # Group listing, search, create dialog
│ │ └── Sidebar.tsx # User profile, navigation, logout
│ │
│ ├── context/
│ │ └── AuthContext.tsx # JWT token management + session restoration
│ │
│ ├── lib/
│ │ ├── api.ts # Typed fetch wrapper + endpoint definitions
│ │ └── socket.ts # Socket.IO client singleton
│ │
│ ├── pages/
│ │ ├── AuthPage.tsx # Login/Register with animated tab switching
│ │ └── ChatPage.tsx # 3-column layout (sidebar/groups/chat)
│ │
│ ├── App.tsx # Root component with auth-gated routing
│ ├── index.css # Tailwind v4 theme (custom palette + glassmorphism)
│ └── main.tsx # React DOM entry
│
├── index.html # SEO meta tags, Inter font, favicon
├── vite.config.ts # Dev proxy (/api + /socket.io → backend)
└── package.json
- Node.js ≥ 22 (for
--env-fileflag support) - PostgreSQL instance (local or Supabase)
- npm ≥ 10
git clone https://github.com/D3S-Gaurav/chat-fullstack.git
cd chat-fullstackcd backend
cp .env.example .env
# Edit .env with your database URL, JWT secret, etc.
npm install
npx prisma generate
npx prisma migrate dev
npm run devThe backend runs on http://localhost:3000 by default.
cd frontend
npm install
npm run devThe frontend runs on http://localhost:5173 and proxies /api and /socket.io to the backend.
Navigate to http://localhost:5173 — register an account, create a group, and start chatting.
Run the full stack with Docker Compose:
docker compose up --buildThis starts:
- PostgreSQL 17 on port 5432
- Backend on port 3000 (with health check)
- Frontend (nginx) on port 80
The backend container runs Prisma migrations on start. See docker-compose.yml for configuration.
32 tests across 6 suites, running green in CI against a PostgreSQL 17 service container. Coverage spans authentication, RBAC, cursor pagination, rate limiting, Zod validation, and Socket.IO handshake authentication.
cd backend
npm test # run all tests
npm run test:watch # watch modeThe suite issues
DELETE FROMagainst every table between files, so it refuses to run unlessDATABASE_URLpoints at a local database whose name containstest. Override withTEST_DATABASE_URLif needed — never with a database you care about.sudo -u postgres psql -c "CREATE DATABASE chatflow_test OWNER postgres;"
Test categories:
- auth — register/login, duplicate email, bad password, JWT shape
- chat — cursor pagination correctness across the
(groupId, createdAt)composite index - rooms — RBAC: MEMBER cannot perform ADMIN actions
- ratelimit — 30 msg/min limiter returns 429 on the 31st request
- validation — Zod rejects malformed REST payloads
- socket —
io.use()rejects bad/absent tokens at handshake
k6-based load testing harness in backend/load/. See load/README.md for full documentation.
cd backend
npm run load:setup # provision test data
npm run load:socket # WebSocket connection stress
npm run load:messages # message throughput
npm run load:rest # REST endpoint ceiling
npm run load:teardown # clean upResults land in load/results/ — see SUMMARY.md.
Create a backend/.env file based on .env.example:
| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
No | 3000 |
Server port |
NODE_ENV |
No | development |
Environment (development / production / test) |
DATABASE_URL |
Yes | — | PostgreSQL connection string |
CORS_ORIGINS |
No | http://localhost:5173 |
Comma-separated allowed origins |
JWT_SECRET |
Yes | — | Minimum 32 characters |
JWT_EXPIRES_IN |
No | 7d |
Token expiry duration |
TRUST_PROXY |
No | 1 |
Number of reverse proxies to trust |
All endpoints return { success: boolean, data: T }. Errors return { success: false, statusCode, message }.
| Method | Endpoint | Auth | Rate Limit | Description |
|---|---|---|---|---|
POST |
/api/auth/register |
— | 10/15min | Create account |
POST |
/api/auth/login |
— | 10/15min | Get JWT token |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/users/me |
Bearer | Current user profile |
GET |
/api/users |
Bearer (ADMIN) | List all users |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/groups |
Bearer | My groups |
POST |
/api/groups |
Bearer | Create group |
GET |
/api/groups/:id |
Bearer | Group details + members |
PATCH |
/api/groups/:id |
Bearer (ADMIN) | Update group |
POST |
/api/groups/:id/members |
Bearer (ADMIN) | Add member |
DELETE |
/api/groups/:id/members/:userId |
Bearer (ADMIN/self) | Remove member |
| Method | Endpoint | Auth | Rate Limit | Description |
|---|---|---|---|---|
POST |
/api/messages |
Bearer | 30/1min | Send message |
GET |
/api/messages?groupId=&cursor=&limit= |
Bearer | — | Paginated history |
| Method | Endpoint | Description |
|---|---|---|
GET |
/health |
Server + database health check |
| Event | Payload | Description |
|---|---|---|
message:send |
{ groupId, content, tags? } |
Send message (validated + persisted) |
room:join |
{ groupId } |
Subscribe to a group room |
room:leave |
{ groupId } |
Unsubscribe from a group room |
typing:start |
{ groupId } |
Broadcast typing indicator |
typing:stop |
{ groupId } |
Clear typing indicator |
| Event | Payload | Description |
|---|---|---|
message:new |
{ id, content, createdAt, sender, groupId, tags } |
New message broadcast |
room:joined |
{ groupId, userId, username } |
User joined notification |
room:left |
{ groupId, userId, username } |
User left notification |
typing:start |
{ groupId, userId, username } |
Someone is typing |
typing:stop |
{ groupId, userId, username } |
Someone stopped typing |
user:online |
{ userId, username } |
User came online |
user:offline |
{ userId, username } |
User went offline |
error |
{ message } |
Server-side error notification |
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ User │ │ Group │ │ Tag │
├──────────────┤ ├──────────────┤ ├──────────────┤
│ id (PK)│ │ id (PK)│ │ id (PK)│
│ username (UQ)│ │ name │ │ name (UQ)│
│ email (UQ)│ │ description? │ │ │
│ passwordHash │ │ createdAt │ │ │
│ role │ │ │ │ │
│ createdAt │ │ │ │ │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ ┌─────────────────┤ │
│ │ │ │
▼ ▼ │ │
┌──────────────┐ │ ┌─────┴────────┐
│ GroupMember │ │ │ _MessageTag │
├──────────────┤ │ │ (implicit) │
│ id (PK)│ │ ├──────────────┤
│ userId (FK)│ │ │ messageId │
│ groupId (FK)│ │ │ tagId │
│ role │ │ └──────────────┘
│ │ │ ▲
│ @@unique │ │ │
│ [userId, │ ▼ ┌────┘
│ groupId] │ ┌──────────────┐ │
└──────────────┘ │ Message │ │
├──────────────┤ │
│ id (PK)│ │
│ content │───────┘
│ createdAt │
│ senderId (FK)│
│ groupId (FK)│
│ │
│ @@index │
│ [groupId, │
│ createdAt] │
└──────────────┘
Indexes:
GroupMember(groupId)— fast member lookups per groupMessage(groupId, createdAt)— optimized cursor-based paginationMessage(senderId)— fast sender-based queriesGroupMember(userId, groupId)— unique constraint prevents duplicate memberships
Direct message and group chat UI featuring glassmorphism design and real-time indicators.
A demo account is seeded for quick exploration:
demo@chatflow.dev |
|
| Password | ChatFlowDemo!2026 |
After deploying, run
npm run seedin the backend to create the demo account and a welcome group with sample messages.
Built with ☕ and intention.
