Build journal
ARCHITECTUREArchitecture 5 min read

How the Frontend Is Built

Published Jul 6, 2026

Next.js 15 App Router, React 19, and TypeScript, with a per-domain service layer over Axios and Zustand state.

Praxis924's frontend is a Next.js 15 App Router application in TypeScript, and its whole job is to stay boring in the ways that matter. Learners browse /learn, read lessons in a focused viewer, practice in /practice/[lessonId], chat with Lucy, and track progress — while admins drive AI generation in /admin. All of that talks to one FastAPI backend. The interesting engineering isn't any single screen; it's the thin, typed plumbing between the UI and the API that keeps auth, streaming, and layout from turning into a swamp of one-off fetch calls.

The problem

The naive approach is to let every component call fetch or axios directly. It works for a week. Then you need a JWT on every request, and you're pasting the same Authorization header into forty call sites. Then the access token expires and you need refresh logic — in forty places. Then chat and mock interviews need token-by-token streaming, which Axios doesn't do well, so a second HTTP style leaks in ad hoc. Meanwhile React 19 server rendering reads auth state that only exists in the browser's localStorage, and the first client paint disagrees with the server's HTML — a hydration mismatch (React #418). None of these are hard individually; together, uncontrolled, they metastasize.

The design

The frontend has exactly two ways to reach the network and one place to hold session state.

First, a single apiClient (Axios) owns the base URL and the JWT. Every domain gets a typed module in services/content-service.ts, progress-service.ts, chat-service.ts, and so on — and those modules are the only thing components import. A component never sees a URL string.

typescript
1// services/api-client.ts
2import axios from "axios";
3import { useAuthStore } from "../store/auth";
4
5export const apiClient = axios.create({
6  baseURL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api/v1",
7});
8
9// One interceptor injects the JWT for every request.
10apiClient.interceptors.request.use((config) => {
11  const token = useAuthStore.getState().token;
12  if (token) config.headers.Authorization = `Bearer ${token}`;
13  return config;
14});
15
16// services/content-service.ts — typed, thin, the only import components see
17export async function getLesson(id: string): Promise<Lesson> {
18  const { data } = await apiClient.get<Lesson>(`/content/lessons/${id}`);
19  return data;
20}
Powered by AI

Second, streaming does not go through Axios. Lucy and the mock interview need to render tokens as they arrive, so they use a dedicated services/stream.ts built on fetch + Server-Sent Events. It reads the response body as a stream and yields chunks; the component appends each chunk to state. The two transports stay cleanly separated: request/response data through apiClient, token streams through stream.ts.

typescript
1// services/stream.ts — SSE, not Axios
2export async function* streamChat(sessionId: string, body: unknown) {
3  const res = await fetch(`${BASE}/chat/sessions/${sessionId}/message/stream`, {
4    method: "POST",
5    headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
6    body: JSON.stringify(body),
7  });
8  const reader = res.body!.getReader();
9  const decoder = new TextDecoder();
10  while (true) {
11    const { value, done } = await reader.read();
12    if (done) break;
13    yield decoder.decode(value, { stream: true }); // append to chat state
14  }
15}
Powered by AI

Session state lives in one Zustand store, read synchronously from localStorage so a returning user is authenticated on first render without a loading flash. That synchronous read is exactly what causes the hydration trap below.

The gotchas

These are the traps that bit us, and the fixes that stuck.

TrapSymptomFix
Auth read from localStorage during SSRReact #418 hydration mismatch — server renders guest, client's first paint sees the real userGate the first render behind a mounted flag set in useEffect; render neutral until mounted
Hand-rolled fixed inset-0 overlaysDrawer peeks from a corner instead of covering the screenReuse SidePanel / Modal — they createPortal to document.body, escaping any transformed/backdrop-blur ancestor
flex-col container that scrollsSticky footer (compose box, Submit button) pushed off the bottommin-h-0 on the scrolling child: header shrink-0, body flex-1 min-h-0 overflow-y-auto, footer shrink-0
Streaming through AxiosResponse buffers; no token-by-token renderUse services/stream.ts (fetch + SSE) instead

Warning: A position:fixed overlay is positioned relative to the nearest ancestor with a transform, filter, or backdrop-filter — not the viewport. Praxis924's triggers often sit inside a backdrop-blur sticky bar, so a hand-built fixed drawer anchors to that bar and appears in a corner. The SidePanel/Modal primitives portal to document.body precisely to dodge this. Don't hand-roll overlays.

Build it yourself

The minimal version is three files. Create apiClient with one request interceptor that reads a token. Add one services/x-service.ts per backend domain, each exporting typed functions that return the parsed body — components import these, never a URL. Put the token in a Zustand store hydrated from localStorage, and gate any layout that branches on user/token behind a mounted flag. If you have a streaming endpoint, add a separate fetch-based SSE reader; keep it out of Axios entirely. That's enough structure to add a fourth, fifth, and tenth feature without touching the plumbing.

What changes at scale

Once you have real traffic, layer in refresh-token rotation inside the apiClient response interceptor (retry the request transparently on a 401), request cancellation via AbortController for search-as-you-type, and a data-fetching cache (React Query or SWR) over the service functions for dedup and background revalidation. Add error-boundary handling per route segment so a failed lesson fetch degrades gracefully instead of blanking the shell. The key is that all of this bolts onto the two transports and one store you already have — because the discipline of "components never touch URLs" was set on day one, none of these upgrades require touching feature code.

Published in build journal