Frontend for Relay, a real-time Slack-style chat app. Built with React, TypeScript, Tailwind CSS and WebSocket (STOMP) for live messaging, and installable as a PWA on iOS and Android.
README
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.
OWNER/ADMIN/MEMBER on workspaces, OWNER/MEMBER on channels)| | |
| ------------------ | --------------------------------------------------------------------------------------------------------------- |
| 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 |
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 at http://localhost:8080, with
GET /actuator/health as a quick liveness check.cp .env.example .env.local # then fill in the values, see below
npm install
npm run dev
Open 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:
/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.SendMessageRequest.content is documented as minLength: 0 but 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.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.
npm run test / test:watch — Vitest + Testing Library,
jsdom environment (vitest.config.ts, vitest.setup.ts). Add spec files
under src/**/*.{test,spec}.ts(x).npm run test:e2e — Playwright is a dependency but there's no
playwright.config.ts or e2e/ directory yet; add one before this script
will do anything. Would need relay-backend running, same as npm run dev.