A full-stack 1:1 realtime chat reference implementation built with NestJS, Next.js, Socket.IO, PostgreSQL, Prisma, and Redis.
- Email/username authentication with JWT access and refresh tokens.
- User search and direct conversation creation.
- Persistent message history with cursor pagination.
- Socket.IO realtime messaging with optimistic UI reconciliation through
clientMessageId. - Typing indicators, online/offline presence, delivered/read receipts, and own-message edit/delete.
- Redis-backed Socket.IO adapter, presence, and distributed rate limiting.
- Server-side authorization for REST and WebSocket conversation membership.
- Responsive Next.js chat UI using TanStack Query and Tabler Icons.
This repository focuses on practical realtime engineering decisions: idempotent message sends, optimistic reconciliation, cursor pagination, distributed presence, Socket.IO horizontal scaling through Redis Adapter, read receipts, and server-owned authorization. It is a starter/reference implementation, not a turnkey production messaging platform.
- Backend: Node.js, TypeScript, NestJS, Socket.IO, Prisma ORM, JWT, DTO validation.
- Frontend: Next.js, React, TypeScript, Socket.IO Client, TanStack Query, Tabler Icons.
- Infrastructure: npm workspaces, Docker Compose, PostgreSQL, Redis.
- Testing: Node test runner, Supertest, Vitest, Testing Library.
The monorepo is split into:
apps/api: NestJS API and Socket.IO gateway.apps/web: Next.js client.packages/shared: shared event names and safe cross-app types.
REST is used for authentication, user search, conversation management, message history, and message actions. Socket.IO is used for low-latency chat events such as new messages, typing, presence, and receipts.
See docs/ARCHITECTURE.md for a concise architecture note.
- Clients authenticate Socket.IO connections with the access token in
socket.auth.token. - Conversations map to rooms named
conversation:<conversationId>. - Users also join
user:<userId>rooms for targeted presence broadcasts. - The server verifies membership before joining rooms, sending messages, marking receipts, or broadcasting typing.
- The client creates
clientMessageIdvalues for optimistic messages; the backend stores them with a uniqueness constraint per sender to make retries idempotent. - Message state is reconciled from
sending/failedUI state to persistedsent,delivered, andreadstate.
Multiple API instances can share the same PostgreSQL database and Redis instance. Redis powers the Socket.IO adapter, presence state, and rate limiting. If a load balancer allows long-polling transports, configure sticky sessions so all requests for one Socket.IO session reach the same API instance. With WebSocket-only transport, that requirement is reduced.
Requirements: Node.js 24 or compatible, npm 11, Docker, and Docker Compose.
git clone https://github.com/Ricardo-NM/realtime-chat
cd realtime-chat
npm install
cp .env.example .env
docker compose up -d
npm run prisma:generate -w @realtime-chat/api
npm run prisma:migrate -w @realtime-chat/api -- --name init
npm run devOn Windows PowerShell, use this instead of cp:
Copy-Item .env.example .envDefault local URLs:
- Web:
http://localhost:3000 - API:
http://localhost:4000 - Health:
http://localhost:4000/health - Readiness:
http://localhost:4000/ready
Copy .env.example to .env for local development. The example contains local-only placeholder JWT secrets so the app can run after cloning. Replace them before any shared, staged, or public deployment.
| Variable | Purpose |
|---|---|
NODE_ENV |
Runtime mode. Production enables stricter env validation. |
API_PORT |
NestJS API port. |
WEB_PORT |
Reserved local web port value; the current web script runs Next on 3000. |
INSTANCE_ID |
Optional API instance label for logs and scaling tests. |
DATABASE_URL |
PostgreSQL connection string used by Prisma. |
REDIS_URL |
Redis connection string. |
FRONTEND_URL |
Comma-separated CORS allowlist for API and Socket.IO. |
NEXT_PUBLIC_API_URL |
Browser-visible REST API URL. |
NEXT_PUBLIC_SOCKET_URL |
Browser-visible Socket.IO URL. |
JWT_ACCESS_SECRET |
Access token signing secret. |
JWT_REFRESH_SECRET |
Refresh token signing secret. Must differ from access secret in production. |
JWT_ACCESS_EXPIRES_IN |
Access token lifetime, for example 15m. |
JWT_REFRESH_EXPIRES_IN |
Refresh token lifetime, for example 30d. |
Generate Prisma Client:
npm run prisma:generate -w @realtime-chat/apiApply migrations in development:
npm run prisma:migrate -w @realtime-chat/api -- --name initUseful Prisma checks:
npm run prisma:format -w @realtime-chat/api
npm run prisma:validate -w @realtime-chat/apinpm run dev
npm run dev:api
npm run dev:web
npm run format
npm run lint
npm run typecheck
npm run test
npm run buildStart PostgreSQL and Redis before running the full suite:
docker compose up -d
npm run testThe root test script runs API e2e/integration tests and web unit/component tests. API tests require DATABASE_URL, REDIS_URL, and JWT secrets from .env.
realtime-chat/
├── apps/
│ ├── api/ # NestJS REST API, Socket.IO gateway, Prisma schema
│ └── web/ # Next.js chat client
├── packages/
│ └── shared/ # Shared event constants and public types
├── docs/
│ ├── ARCHITECTURE.md
│ ├── SPEC.md
│ ├── images/
│ ├── media/
│ └── stages/
├── docker-compose.yml
├── package.json
└── README.md
All protected endpoints use Authorization: Bearer <accessToken>.
| Method | Path | Purpose |
|---|---|---|
GET |
/, /health |
Basic API status. |
GET |
/ready |
PostgreSQL/Redis readiness status. |
POST |
/auth/register |
Register a user. |
POST |
/auth/login |
Login with email/username and password. |
POST |
/auth/refresh |
Rotate refresh token and issue new tokens. |
POST |
/auth/logout |
Revoke the provided refresh token. |
GET |
/auth/me |
Return the authenticated user. |
GET |
/users/search |
Search users. |
GET |
/users/:id |
Get a public user profile. |
GET |
/conversations |
List conversations for the authenticated user. |
POST |
/conversations |
Create or return a direct conversation. |
GET |
/conversations/:id |
Get a conversation by id if the user is a member. |
GET |
/conversations/:conversationId/messages |
List message history with cursor pagination. |
PATCH |
/messages/:messageId |
Edit an own message. |
DELETE |
/messages/:messageId |
Soft-delete an own message. |
POST |
/presence/status |
Fetch presence status for users. |
Client-to-server events:
| Event | Payload |
|---|---|
conversation:join |
{ conversationId } |
conversation:leave |
{ conversationId } |
message:send |
{ conversationId, content, clientMessageId } |
message:delivered |
{ conversationId, messageId } |
conversation:read |
{ conversationId, upToMessageId } |
typing:start |
{ conversationId } |
typing:stop |
{ conversationId } |
Server-to-client events:
| Event | Purpose |
|---|---|
connection:ready |
Confirms authenticated socket connection. |
message:new |
Broadcasts a persisted message. |
message:delivered |
Broadcasts delivery receipt state. |
message:read |
Broadcasts read receipt state. |
message:edited |
Broadcasts edited message state. |
message:deleted |
Broadcasts soft-deleted message state. |
typing:start, typing:stop |
Broadcast typing state to other conversation members. |
presence:online, presence:offline |
Broadcast relevant user presence transitions. |
Canonical event names and TypeScript payloads live in packages/shared/src/index.ts.
- Passwords are hashed with bcrypt before storage.
- JWT identity is server-derived; clients cannot choose user identity by ID.
- REST and Socket.IO paths validate conversation membership on the server.
- Helmet, CORS allowlisting, payload limits, DTO validation, and public exception filtering are enabled.
- HTTP and WebSocket rate limits use Redis with an in-process fallback for isolated Redis failures.
- The current frontend stores access and refresh tokens in
localStorage. This is acceptable for a learning/reference app but carries XSS risk. For real public deployments, migrate refresh tokens toHttpOnly,Secure,SameSitecookies and keep access tokens in memory. - Never commit
.envfiles or real secrets.
- Auth tokens are currently stored in
localStorage. - Groups are modeled but not complete as a user-facing feature.
- No uploads, images, files, audio messages, object storage, reactions, push notifications, calls, or E2E encryption.
- Redis high availability is not configured by this repository.
- Sticky sessions may be required by the load balancer when long-polling transports are enabled.
lastMessagein the conversation list remainsnull.- No browser E2E suite is currently configured.
- This is a starter/reference implementation, not a turnkey production messaging platform.
- Complete group conversations.
- Add file/image uploads backed by object storage.
- Move refresh tokens to
HttpOnlycookies for production-style auth. - Add browser E2E coverage for the core chat flow.
- Add notifications and richer message interactions.
- Document deployment examples without coupling the project to one provider.
Contributions are welcome if they stay within the project scope and keep the reference implementation clear. See CONTRIBUTING.md.
MIT. See LICENSE.