A full-stack 1:1 messaging app: JWT auth, friend invitations,
and live messaging over a single multiplexed WebSocket.
Go 1.26 · PostgreSQL 17 · React 19 · TypeScript · TanStack Router/Query · Tailwind v4
| Authentication | Signup, login, logout, and silent token refresh. Access + refresh JWTs as bearer tokens. |
| Friend invitations | Invite by username, accept (creates the chat), reject, list pending, remove friend. |
| Real-time messaging | Live delivery over one multiplexed WebSocket per user, with paginated history over REST. |
| Presence of failure | Automatic socket reconnection with capped exponential backoff and a live connection indicator. |
chat-app/
├── backend/ Go API + WebSocket hub
│ ├── cmd/api/ composition root (wiring, env, graceful config)
│ └── internal/
│ ├── api/ HTTP handlers, middleware, WS hub + client pumps
│ ├── services/ business logic (user, friend, chat, message)
│ ├── store/pgstore/ sqlc-generated queries + tern migrations
│ ├── jwtutils/ token minting and parsing
│ └── validator/ request validation
└── frontend/ React SPA
└── src/
├── components/atoms/ Button, Input, Tab, Avatar, Logo
├── modules/ auth · chat · friends (api / hooks / components)
├── lib/ fetch client, query keys, formatting
└── routes/ file-based routes + auth guards
The backend follows a layered pattern — handlers stay thin, services own the business rules and
transactions, and store/pgstore is generated from hand-written SQL by sqlc. Errors are declared
as sentinel values in the service layer (ErrNotParticipant, ErrInviteAlreadyResolved, …) and
mapped to status codes at the edge, so HTTP concerns never leak inward.
These were deliberate, and are the parts of the project worth reading.
Auth is a bearer token — after httpOnly cookies turned out to be unshippable.
Cookies were the original design, and the better one: unreachable from JavaScript, so a
successful XSS cannot exfiltrate a session. What broke it was deployment topology. The frontend
is on *.vercel.app and the API on *.onrender.com — unrelated registrable domains, which
makes the auth cookies third-party. WebKit blocks those outright, and every browser on iOS
is WebKit, so Safari, Chrome and Brave on iPhone all took a 200 OK from /auth/login,
silently dropped the cookie, and bounced the user back to the login screen. It worked on
desktop, which is exactly what made it easy to miss.
Bearer tokens have no origin rules, so they work regardless of how the two hosts relate. The
cost is real and worth naming: the token lives in localStorage, where a successful XSS can
read it. The WebSocket pays a smaller tax too — its upgrade cannot carry an Authorization
header, so the token rides in Sec-WebSocket-Protocol (not the query string, which the request
logger would capture). Putting both services on one registrable domain would allow
SameSite=Lax cookies again, and is the fix worth making when the app gets a real domain.
One multiplexed socket per user, not one per chat.
Every envelope carries a chat_id; a single hub goroutine owns the client map — so it needs no
mutex — and fans messages out to connected participants. Opening a conversation in the UI is
pure client state, with no connection churn.
The server's echo is the ack. The hub fans a persisted message out to all participants including its sender. The frontend therefore inserts nothing optimistically: the echo is the single path a message takes into the cache, which means there are no temporary IDs to reconcile and no duplicate-key edge cases.
"Friends" are not a table — they are the user's 1:1 chats.
Accepting an invitation creates the chat, both participant rows, and marks the invite accepted in
one transaction, so a chat can never exist without its participants. Removing a friend
hard-deletes the chat inside a transaction and lets ON DELETE CASCADE take its participants and
messages with it.
Token refresh is single-flight. A 401 triggers one shared refresh request; concurrent callers await the same promise and then retry. A burst of parallel queries hitting an expired access token produces exactly one refresh, not one per query.
Reconnect backoff resets on stability, not on connect. Since the hub allows one socket per user and evicts the previous one, two open tabs would otherwise evict each other in a tight loop. The backoff counter only resets once a connection has held for 10s, so a losing socket backs off instead of hammering the upgrade endpoint.
A single JSON envelope in both directions, discriminated by a numeric kind:
| kind | direction | payload |
|---|---|---|
0 SendMessage |
client → server | chat_id, content |
1 NewMessage |
server → participants | id, chat_id, sender_id, content, sent_at |
2 Error |
server → sender | message |
3 InvalidJSON |
server → sender | message |
sender_id is always taken from the authenticated connection, never from the client payload —
a client cannot send a message as someone else.
All routes are under /api/v1.
POST /auth/signup create account
POST /auth/login returns access + refresh tokens
POST /auth/refresh mint a fresh access token
POST /auth/logout no-op; the client discards its tokens
GET /auth/me current user
POST /friends/invites/ invite by username
GET /friends/invites/ pending invitations
POST /friends/invites/{id}/accept accept → creates the chat
POST /friends/invites/{id}/reject reject
GET /friends/ friends list
DELETE /friends/{chat_id} remove friend
GET /chats/ chat list with last-message preview
GET /chats/{chat_id}/messages history (?before=RFC3339&limit=)
GET /ws WebSocket upgrade
Prerequisites: Go 1.26+, Node 20+, Docker.
# 1. Database
cd backend
cp .env.example .env # defaults work for local development
docker compose up -d
# 2. Migrations
make migrate
# 3. API → http://localhost:3080
make run-api # or: go run ./cmd/api
# 4. Frontend → http://localhost:5173
cd ../frontend
npm install
npm run devThe Vite dev server proxies /api (including the WebSocket upgrade) to the Go API, so the
browser stays on a single origin and local dev needs no CORS configuration.
The frontend is on Vercel, the API and its Postgres on Railway. The API is a container built
from backend/Dockerfile, which runs migrations in the foreground before starting the server so
a failed migration exits rather than serving against a schema that was never applied.
| Variable | Value | Notes |
|---|---|---|
DATABASE_URL |
${{Postgres.DATABASE_URL}} |
A platform reference, not a literal. Preferred over the five CHATAPP_DATABASE_* vars, which stay behind for docker-compose. The service-name prefix must match the database service exactly — a reference that doesn't resolve arrives as an empty string. |
CHATAPP_JWT_SECRET |
openssl rand -base64 48 |
Absent → the process exits at startup. |
CHATAPP_ALLOWED_ORIGINS |
https://chat-wep-app.vercel.app |
Comma-separated, no trailing slash. Absent → no CORS middleware is installed at all and every cross-origin preflight fails. |
CHATAPP_ACCESS_TOKEN_TTL |
15m |
|
CHATAPP_REFRESH_TOKEN_TTL |
168h |
|
PORT |
injected by the platform | Do not set by hand. |
CHATAPP_API_ADDR must not be set in a deployed environment. It takes priority over PORT,
so the server binds an address the platform's proxy isn't routing to, and every request — CORS
preflights included — comes back 502 from the edge. A preflight that 502s carries no
Access-Control-Allow-Origin, so the browser reports it as a CORS failure; the CORS config is a
red herring whenever the same URL also fails from curl.
The two failure modes worth recognising from the logs:
Error loading config: strconv.ParseUint: parsing "": invalid syntax— tern, not the app. The discrete database vars are empty. SetDATABASE_URL.502withx-railway-fallback: trueon every route including/api/v1/healthz— no healthy container. The app is crash-looping or bound to the wrong port; read the deploy logs rather than the browser console.
The interface is built from a small design system — dark violet-on-slate, Sora for headings and
Manrope for body — defined as Tailwind v4 theme tokens in frontend/src/index.css and consumed
through tailwind-variants atoms.
The mark is a single <Logo /> component rather than a set of exported image assets: one
component covers every size and all three surface treatments above — the outline variant carries
the violet ramp on the glyph itself, so the gradient survives on a dark card. Corner radius is
30% of the box at every size, so the squircle keeps its proportions.
MVP feature-complete and manually verified end to end against a live database. Known gaps, kept visible rather than papered over:
- No automated tests yet — the highest-value next step.
- Invitations are polled, not pushed. The socket carries chat messages only, so incoming invites refresh on a 20s interval.
- No online-presence tracking. The hub knows who is connected but does not broadcast it, so the UI shows connection state rather than per-contact presence.
- No message pagination in the UI. The API supports a
beforecursor; the client currently loads only the most recent page.