A production-grade, full-stack realtime chat application — Spring Boot backend, React + Vite frontend, PostgreSQL, JWT auth with refresh-token rotation, and WebSocket (STOMP) messaging end to end.
Single-repo monolith:
backend/(Spring Boot 3 · Java 21 · Maven) andfrontend/(React 18 · Vite · Tailwind). Ships with a completedocker-composestack including PostgreSQL and a mailpit SMTP catcher.
- Auth — register / login / refresh-with-rotation / logout / forgot+reset password, BCrypt(12) hashing, role-based (USER · ADMIN), CORS hardened, rate-limited.
- Realtime messaging — 1:1 and group chats, typing indicators, read receipts, online presence, browser + audible notifications.
- Rich messages — text, emoji, image and file attachments (15 MB cap), edit, soft-delete, pin / unpin, message search across your rooms, pinned-message strip.
- Groups — create, rename, add / remove members, leave, member roles (OWNER / ADMIN / MEMBER).
- Profile — display name, bio, avatar upload, last-seen, online dot, search the directory.
- Admin panel — system stats, paginated user list, suspend / re-activate accounts, change roles (with self-action guard).
- Polished UI — distinct dark / light themes (jade-green accent on warm paper / deep ink surfaces), refined typography (Bricolage Grotesque + Hanken Grotesk), subtle film-grain texture, responsive down to mobile.
- Docs — Swagger UI for the entire REST API.
- DevOps — multi-stage Dockerfiles,
docker-composewith health-checks, persistent volumes for Postgres and uploaded files,.env.examplefor all knobs.
| Layer | Choice |
|---|---|
| Backend | Java 21 · Spring Boot 3.3 · Spring Security · Spring Data JPA · Spring WebSocket (STOMP + SockJS) · Spring Mail · Spring Boot Actuator |
| Auth | JJWT 0.12 (HS256 access tokens) · opaque DB-backed refresh tokens with rotation |
| Database | PostgreSQL 16 |
| Docs | springdoc-openapi (Swagger UI) |
| Frontend | React 18 · Vite 5 · React Router 6 · Axios · @stomp/stompjs · sockjs-client · Tailwind CSS 3 |
| Build / Run | Maven · npm · Docker · docker compose · nginx (frontend reverse proxy) |
realtime-chat-hub/
├── backend/ Spring Boot service (com.chathub)
│ ├── pom.xml
│ ├── Dockerfile multi-stage Maven → JRE 21
│ └── src/main/
│ ├── java/com/chathub/
│ │ ├── ChatHubApplication.java
│ │ ├── config/ AppProperties · SecurityConfig · WebSocketConfig …
│ │ ├── controller/ 7 REST controllers (auth, users, chats, messages, …)
│ │ ├── dto/ request/response records
│ │ ├── entity/ User · ChatRoom · Message · …
│ │ ├── exception/ typed exceptions + global handler
│ │ ├── repository/ Spring Data JPA repositories
│ │ ├── security/ JWT filter · UserDetails · SecurityUtils
│ │ ├── service/ business logic
│ │ └── websocket/ STOMP auth interceptor · presence listener · controller
│ └── resources/application.yml
├── frontend/ React + Vite app
│ ├── package.json
│ ├── vite.config.js
│ ├── tailwind.config.js
│ ├── nginx.conf SPA + reverse proxy used by the Docker image
│ ├── Dockerfile multi-stage Node → nginx
│ └── src/
│ ├── api/ axios client + per-resource modules
│ ├── components/ Sidebar · ChatWindow · MessageBubble · modals · icons …
│ ├── context/ Auth · Chat · Theme · Toast providers
│ ├── hooks/ useStomp (STOMP-over-SockJS lifecycle)
│ ├── pages/ Login · Register · ChatPage · AdminPage · …
│ ├── utils/ constants · formatting helpers
│ ├── App.jsx
│ └── main.jsx
├── docker-compose.yml
├── .env.example
└── README.md
Prerequisites: Docker 24+ and Docker Compose v2.
git clone <this-repo> realtime-chat-hub
cd realtime-chat-hub
cp .env.example .env
docker compose up --buildWhen all three containers report healthy:
| Service | URL |
|---|---|
| Web UI | http://localhost:5173 |
| API (direct) | http://localhost:8080 |
| Swagger UI | http://localhost:5173/swagger-ui.html |
| Mailpit inbox | http://localhost:8025 |
| Postgres | localhost:5432 (chathub / chathub) |
| Username | Password | Role |
|---|---|---|
admin |
Admin@12345 |
ADMIN |
alice |
Alice@12345 |
USER |
bob |
Bob@12345 |
USER |
Set SPRING_PROFILES_ACTIVE=prod in .env to seed only the admin account.
Requirements: JDK 21, Maven 3.9+, a running PostgreSQL 16 with database chathub and user chathub/chathub. Optionally a local SMTP catcher on localhost:1025 (e.g. docker run -p 1025:1025 -p 8025:8025 axllent/mailpit).
cd backend
export DB_HOST=localhost DB_PORT=5432
export DB_NAME=chathub DB_USERNAME=chathub DB_PASSWORD=chathub
mvn spring-boot:runThe first launch creates all tables (spring.jpa.hibernate.ddl-auto=update) and seeds the demo accounts. Swagger UI is at http://localhost:8080/swagger-ui.html.
Requirements: Node 20+.
cd frontend
cp .env.example .env # optional — defaults are fine
npm install
npm run dev # http://localhost:5173 with Vite proxy → backendThe Vite dev server proxies /api, /ws and /uploads to http://localhost:8080 (override with VITE_PROXY_TARGET).
All variables have safe defaults; only DB_PASSWORD and JWT_SECRET should be changed in production.
| Variable | Default | Purpose |
|---|---|---|
DB_HOST / DB_PORT / DB_NAME / DB_USERNAME / DB_PASSWORD |
postgres / 5432 / chathub / chathub / chathub |
PostgreSQL connection |
JWT_SECRET |
(dev secret) | Base64-encoded HMAC-SHA secret for access tokens |
JWT_ACCESS_EXPIRATION |
900000 (15 min) |
Access token TTL in ms |
JWT_REFRESH_EXPIRATION |
604800000 (7 d) |
Refresh token TTL in ms |
CORS_ALLOWED_ORIGINS |
http://localhost:5173,http://localhost:3000 |
Comma-separated allow-list |
UPLOAD_DIR |
uploads (container: /app/uploads) |
File-storage directory |
FRONTEND_RESET_URL |
http://localhost:5173/reset-password |
Prepended to password-reset tokens |
MAIL_HOST / MAIL_PORT / MAIL_USERNAME / MAIL_PASSWORD |
mailpit defaults | SMTP for password-reset emails |
MAIL_FROM |
no-reply@chathub.local |
Sender address |
RATE_LIMIT_CAPACITY / RATE_LIMIT_REFILL_SECONDS |
100 / 60 |
Per-IP token-bucket limit (skips /ws, /uploads, swagger) |
SPRING_PROFILES_ACTIVE |
default |
prod disables alice / bob seeding |
Base path: /api. Every protected endpoint requires Authorization: Bearer <accessToken>. Refresh tokens are issued alongside the access token and rotated on every call to /auth/refresh.
| Method | Path | Description |
|---|---|---|
| POST | /register |
Create an account; returns access + refresh tokens. |
| POST | /login |
Authenticate with username-or-email + password. |
| POST | /refresh |
Exchange a refresh token for a fresh pair (rotates). |
| POST | /logout |
Revoke a refresh token. |
| POST | /forgot-password |
Email a one-time reset link. |
| POST | /reset-password |
Consume a reset token + set a new password. |
| GET | /me |
Currently signed-in user. |
| Method | Path | Description |
|---|---|---|
| GET | /search?q=&page=&size= |
Search the directory by username / display name. |
| GET | /{id} |
Fetch a user. |
| PUT | /me |
Update display name / bio. |
| PUT | /me/avatar |
Multipart upload of a new avatar. |
| Method | Path | Description |
|---|---|---|
| GET | / |
All chats for the current user. |
| GET | /search?q= |
Search the user's groups. |
| GET | /{roomId} |
Fetch one chat (member-only). |
| POST | /private |
Open or fetch a 1:1 chat with a target user. |
| POST | /group |
Create a group { name, memberIds[] }. |
| PUT | /group/{roomId}/rename |
Rename a group. |
| POST | /group/{roomId}/members |
Add member { userId }. |
| DELETE | /group/{roomId}/members/{userId} |
Remove a member (or leave). |
| Method | Path | Description |
|---|---|---|
| POST | / |
Send a message into a room. |
| GET | /room/{roomId}?page=&size= |
Paged history (newest first). |
| GET | /room/{roomId}/pinned |
Pinned messages. |
| GET | /search?q=&page=&size= |
Full-text search across the user's rooms. |
| PUT | /{messageId} |
Edit content (author only). |
| DELETE | /{messageId} |
Soft-delete (author only). |
| PUT | /{messageId}/pin?pinned= |
Pin / unpin (room members). |
| PUT | /room/{roomId}/read?lastReadMessageId= |
Update read-pointer. |
| Method | Path | Description |
|---|---|---|
| GET | / |
Recent in-app notifications. |
| GET | /unread-count |
{ count }. |
| PUT | /{id}/read |
Mark one read. |
| PUT | /read-all |
Mark all read. |
| Method | Path | Description |
|---|---|---|
| POST | /upload |
Multipart file; returns { url, name, contentType, size }. |
Uploaded files are served back via /uploads/<id>.<ext>. Validated by content-type and extension; max 15 MB.
| Method | Path | Description |
|---|---|---|
| GET | /stats |
Aggregate counters. |
| GET | /users?page=&size= |
Paginated user list. |
| PUT | /users/{userId}/status?enabled= |
Suspend / re-activate. |
| PUT | /users/{userId}/role?role= |
Promote / demote. |
Admins cannot suspend or change the role of their own account.
WebSocket handshake endpoint: /ws (SockJS). The JWT access token is sent as the STOMP Authorization: Bearer <token> header on CONNECT.
| Destination | Payload | Notes |
|---|---|---|
/topic/chat.{roomId} |
ChatSocketEvent (envelope { type, chatRoomId, payload }) |
Room events: NEW_MESSAGE, EDIT_MESSAGE, DELETE_MESSAGE, PIN_MESSAGE, TYPING, READ_RECEIPT, GROUP_RENAMED, MEMBER_ADDED, MEMBER_REMOVED. |
/topic/presence |
{ userId, online, lastSeen } |
Global presence broadcast. |
/user/queue/chats |
ChatRoomDto or ChatSocketEvent (ROOM_UPDATED, MEMBER_REMOVED) |
Sidebar updates: new room, last-message bump, removed-from-group. |
/user/queue/notifications |
NotificationDto |
In-app notifications targeted at the current user. |
| Destination | Payload | Effect |
|---|---|---|
/app/chat.typing |
{ chatRoomId, typing: boolean } |
Broadcasts a typing event to room members. |
/app/chat.read |
{ chatRoomId, lastReadMessageId } |
Updates the sender's read-pointer + broadcasts READ_RECEIPT. |
Sending a message is a plain REST POST /api/messages; the resulting NEW_MESSAGE event is fanned out to the room topic and triggers a ROOM_UPDATED event for every other member's sidebar queue.
- Stateless API + stateful WebSocket. Spring Security uses
STATELESSsession creation; the JWT filter authenticates each REST request, and aChannelInterceptorauthenticates each STOMPCONNECTframe with the same access token. - Refresh-token rotation. Refresh tokens are opaque random strings persisted in
refresh_tokenswithexpires_atandrevokedflags. On every/auth/refreshcall the presented token is revoked and a fresh one is issued; suspending an account revokes all of that user's refresh tokens. - Presence. A
WebSocketEventListenerreacts toSessionConnected/SessionDisconnect, updatesusers.online+users.last_seen, and broadcasts aPresenceEventto/topic/presence. The frontend merges the event stream with the seed state from each room's member list. - Read pointers.
chat_members.last_read_message_idis the single source of truth for unread counts (countByChatRoomIdAndIdGreaterThanAndSenderIdNot). The frontend marks-read both on REST history fetch and on everyNEW_MESSAGEarriving in the active room. - Soft-delete. Messages set
deleted=trueand the DTO mapper nulls their content; the UI renders a "This message was deleted" pill. - Rate limiting. A simple per-IP fixed-window filter (
RateLimitFilter, capacity / refill configurable) runs ahead of Spring Security and emits the standardApiErrorJSON envelope. - Auditing. Every entity extends
BaseEntitywith@CreatedDate/@LastModifiedDatetimestamps. - Scheduled cleanup.
ScheduledTasksdeletes expired refresh + password-reset tokens hourly.
- BCrypt strength 12 for password hashing.
- HS256-signed JWTs carrying
userId,username,roleclaims. - Refresh tokens are opaque DB-stored values (not JWTs) — instantly revocable.
- All admin endpoints are double-locked (URL match in
SecurityConfigand@PreAuthorize("hasRole('ADMIN')")). - File uploads are validated by allow-listed content-type and extension and capped at 15 MB.
- CORS allow-list is explicit (no
*); credentials are not exposed in the access token URL. Authenticationprincipal is bound onto the STOMP session so@MessageMappinghandlers know exactly who sent the frame.
For production: set a fresh JWT_SECRET (≥256 bits, base64-encoded), point MAIL_* at a real SMTP provider, set SPRING_PROFILES_ACTIVE=prod to skip demo seeding, and front the stack with HTTPS (the nginx config in frontend/ is the natural place to terminate TLS).
# Backend
cd backend
mvn spring-boot:run # dev run (requires Postgres)
mvn clean package # build the fat jar (target/realtime-chat-hub-1.0.0.jar)
mvn test # run tests
# Frontend
cd frontend
npm run dev # Vite dev server on :5173
npm run build # production bundle into dist/
npm run preview # preview the production bundle
npm run lint # ESLint- The backend was developed and statically audited in an environment that did not have access to Maven Central (sandbox network policy blocked
repo.maven.apache.org). Maven 3.9.x and JDK 21 are installed; the code compiles cleanly given a working Maven dependency cache, and the suppliedbackend/Dockerfileis the recommended way to build it (it downloads dependencies as part of themvn packagestep). On any normal developer machine or CI runner with internet access,mvn -DskipTests packageinsidebackend/will produce the runnable jar. - The frontend was built end to end in this environment (Vite production build, 252 modules, no errors / warnings) and the resulting bundle is what
frontend/Dockerfilereproduces. - All wiring between the frontend and the backend was cross-checked by reading the DTO records, the controller signatures, and the STOMP destinations — they match exactly. Where the API contract has rough edges (e.g.
NotificationDto.referenceIdcarrying the room id forNEW_MESSAGE), the conventions are documented above. application.ymlusesspring.jpa.hibernate.ddl-auto=update, which is fine for development. For production migrations you'll likely want to introduce Flyway or Liquibase.
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.