A Slack-style chat client built with Next.js for relay-backend, a Spring Boot microservices backend (auth, workspaces, channels, messaging, presence, files, notifications — each its own service behind an API gateway).
Relay is not a standalone app: it needs relay-backend running to do anything beyond render the login screen. See Prerequisites.
- Auth — register/login, session refresh via an httpOnly cookie, CSRF-protected
- Workspaces & channels — create/rename, public/private channels, role-based
access (
OWNER/ADMIN/MEMBERon workspaces,OWNER/MEMBERon channels) - Messaging — real-time via WebSocket (STOMP over SockJS), threads, emoji reactions, edit/delete, consecutive-message grouping, markdown content (sanitized before render)
- File attachments — upload and attach files/images to a message; images render inline, other files as a download chip
- Presence & typing indicators — live online/offline status and per-channel typing indicators over a dedicated presence WebSocket
- Notifications — paginated inbox, unread badge, mark-as-read
- User profiles — click any avatar/name to open a profile card (avatar, banner, presence, join date); edit your own display name, avatar, and banner from Settings
- Workspace administration — rename a workspace, manage its members, all from one settings page
- Theming — light/dark/system, persisted, no flash on load
- Access control enforced end-to-end — the frontend hides actions a user isn't allowed to take, but every check is also backed by the corresponding relay-backend authorization rule (see Notes on the backend contract)
| Framework | Next.js 16 (App Router, Turbopack) + React 19 |
| Language | TypeScript |
| Styling | Tailwind CSS v4 |
| UI primitives | Base UI + a shadcn-style component layer in src/components/ui |
| Server state | TanStack Query |
| Client state | Zustand |
| Forms & validation | react-hook-form + Zod |
| Realtime | @stomp/stompjs over SockJS |
| Icons | lucide-react |
| Markdown | react-markdown + remark-gfm + rehype-sanitize |
| Testing | Vitest + Testing Library (unit), Playwright (e2e) |
| Tooling | ESLint, Prettier, Husky + lint-staged |
- Node.js 20+ (required by Next.js 16 / React 19) and npm.
- relay-backend running locally, reachable at the URL you set in
NEXT_PUBLIC_API_BASE_URL. relay-backend is a separate repo — this frontend won't build a session, load a workspace, or do anything backend-dependent without it. Its api-gateway typically runs athttp://localhost:8080, withGET /actuator/healthas a quick liveness check.
cp .env.example .env.local # then fill in the values, see below
npm install
npm run devOpen http://localhost:3000.
Set in .env.local (see .env.example):
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_API_BASE_URL |
yes | URL of relay-backend's api-gateway. Public — the browser calls it directly for both REST and WebSocket traffic. Defaults to http://localhost:8080. |
CSRF_SECRET |
yes | Server-only secret used to sign the CSRF double-submit token issued by the BFF auth routes (src/app/api/auth/*). Generate with openssl rand -base64 32. |
| Command | Description |
|---|---|
npm run dev |
Start the dev server (Turbopack) |
npm run build |
Production build |
npm run start |
Serve a production build |
npm run lint |
ESLint |
npm run format / format:check |
Prettier write / check |
npm run typecheck |
tsc --noEmit |
npm run test / test:watch |
Unit tests (Vitest) |
npm run test:e2e |
End-to-end tests (Playwright) |
A Husky pre-commit hook runs lint-staged (ESLint --fix + Prettier) on staged
files automatically — no manual step needed.
src/
app/ Next.js App Router routes
(auth)/ login, register — public, redirects away if already signed in
(app)/ everything behind RequireAuth
workspaces/ workspace list, per-workspace layout + channel view + settings
notifications/
settings/ your own profile + appearance
api/auth/ BFF routes: login/logout/refresh/session (see below)
features/ One folder per domain, each shaped the same way:
<feature>/
api.ts fetch calls to relay-backend, Zod-validated responses
types.ts Zod schemas + inferred types, client-side validation
hooks.ts TanStack Query hooks (queries + mutations)
store.ts Zustand store, only where genuinely client-only state is needed
components/
auth/ workspaces/ channels/ messages/ presence/ files/ notifications/ settings/
components/
ui/ Base UI-backed primitives (button, dialog, dropdown-menu, ...)
layout/ App shell pieces (topbar, empty states)
lib/
api/ Shared fetch client, error type, pagination helper, config
auth/ JWT decode, cookie helpers, Zod session schemas, auth Zustand store
ws/ STOMP client factory + messaging/presence socket hooks
avatarColor.ts Deterministic per-id color (avatars, workspace icons)
useObjectUrl.ts Fetch an authenticated image endpoint into a blob URL for <img>
Auth is a BFF (backend-for-frontend) pattern. The access token lives only
in memory (a Zustand store) — never in localStorage or a JS-readable cookie.
The refresh token is an httpOnly cookie, set by src/app/api/auth/login
(a Next.js Route Handler that proxies to relay-backend and sets the cookie on
the response). A page reload calls src/app/api/auth/session to silently
redeem that cookie for a fresh access token. lib/api/client.ts's apiFetch
attaches the Bearer token to every relay-backend call and retries once on 401
via a de-duplicated refresh (concurrent 401s share one in-flight refresh call,
since the refresh token is single-use).
Authenticated image/file content. Endpoints like a user's avatar/banner or
a message's file attachment require the same Bearer auth as everything else —
a plain <img src="..."> can't attach a header. lib/useObjectUrl.ts fetches
the endpoint via apiFetch, turns the response into a blob URL, and manages
its lifecycle (revoke on unmount/change).
Realtime is two separate STOMP connections (lib/ws/): one for messaging
(per-channel subscription, reconnects re-subscribe automatically) and one for
presence + typing, held open for the app session. Sending a message is a plain
REST POST, not a WS publish — the server broadcasts it back over the socket to
everyone including the sender.
Security headers. next.config.ts sets a Content-Security-Policy (scoped
to NEXT_PUBLIC_API_BASE_URL for connect-src, relaxed for inline scripts
only in development), plus X-Content-Type-Options, Referrer-Policy, and
X-Frame-Options: DENY.
Message content is sanitized. MessageContent renders markdown through
rehype-sanitize — never dangerouslySetInnerHTML on anything that came from
the API.
relay-backend's contract has evolved alongside this frontend (new endpoints added as features needed them — username lookup, message attachments, user avatar/banner, workspace membership authorization). A few things worth knowing if you're touching backend-dependent code:
- Each service exposes
/v3/api-docs(springdoc) — check it directly rather than assuming a shape, since it's the source of truth and has changed mid-development more than once. - Not everything documented in the OpenAPI spec is complete: for example
SendMessageRequest.contentis documented asminLength: 0but the backend actually enforces "content, at least one attachment, or both" — verify behavior live, not just against the spec, when something looks like it should work but doesn't. - Workspace/channel authorization (who can add/remove members, rename, etc.)
is enforced server-side, not just hidden in the UI — the frontend's
role-based gating (
useMyWorkspaceRole,useIsChannelMember, ...) mirrors what the backend actually checks.
Tooling is set up but no test suites have been written yet — both commands below currently exit with nothing to run.
- Unit tests:
npm run test/test:watch— Vitest + Testing Library, jsdom environment (vitest.config.ts,vitest.setup.ts). Add spec files undersrc/**/*.{test,spec}.ts(x). - E2E:
npm run test:e2e— Playwright is a dependency but there's noplaywright.config.tsore2e/directory yet; add one before this script will do anything. Would need relay-backend running, same asnpm run dev.