How the Frontend Is Built
Published Jul 6, 2026
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.
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}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.
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}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.
| Trap | Symptom | Fix |
|---|---|---|
Auth read from localStorage during SSR | React #418 hydration mismatch — server renders guest, client's first paint sees the real user | Gate the first render behind a mounted flag set in useEffect; render neutral until mounted |
Hand-rolled fixed inset-0 overlays | Drawer peeks from a corner instead of covering the screen | Reuse SidePanel / Modal — they createPortal to document.body, escaping any transformed/backdrop-blur ancestor |
flex-col container that scrolls | Sticky footer (compose box, Submit button) pushed off the bottom | min-h-0 on the scrolling child: header shrink-0, body flex-1 min-h-0 overflow-y-auto, footer shrink-0 |
| Streaming through Axios | Response buffers; no token-by-token render | Use 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.