Frontend API Integration
The Next.js app communicates with the FastAPI backend through two separate API clients depending on execution context. Using the wrong client causes missing auth headers or hydration errors.
The Two Clients
| Client | File | Used In | Auth Method |
|---|---|---|---|
fetchWrapper | src/api/fetch.ts | Server Components (initial page data) | Reads session server-side, injects Bearer token |
axiosClient | src/api/axiosClient.ts | Client Components (interactive data) | Session cookie handled by Next-Auth; no manual token management |
Rule of thumb: If your component has "use client" at the top, use Axios + React Query. If it’s a Server Component (no "use client"), use fetchWrapper.
fetchWrapper (Server-Side)
src/api/fetch.ts is marked 'use server'. It wraps the native fetch() API with automatic Bearer token injection.
const response = await fetchWrapper('/api/cases', { method: 'GET',});const data = await response.json();Key behaviors:
- Retrieves the access token from the NextAuth session via
await auth() - Sets
Authorization: Bearer <token>on every request - On 401: calls
redirect('/login')— kicks the user out immediately - On other 4xx/5xx: returns the response object without throwing — the caller must check
response.ok - For file uploads: pass
isFileUpload: trueto skip theContent-Type: application/jsonheader - Cache control: by default responses are cached; pass
cache: falsefor no-store
When to use cache: false:
Pass cache: false on data that must be fresh every request (e.g., payment statuses, notification counts). Leave the default for data that rarely changes (user profile, case metadata).
Code:
app/src/api/fetch.ts
axiosClient (Client-Side)
src/api/axiosClient.ts is a configured Axios instance used in React Query hooks and mutation handlers.
import axiosClient from '@/api/axiosClient';
const { data } = await axiosClient.get('/api/cases');Key behaviors:
- 401 interceptor: On any 401 response, resets PostHog and calls
signOut()from next-auth/react, forcing a full logout. No manual token refresh needed — NextAuth handles refresh transparently before requests reach this client. - Empty
baseURL— all paths are relative (e.g.,/api/cases), resolved against the origin. - Default
Content-Type: application/json
In practice, you never call axiosClient directly in components. It is used inside API client modules (see API Client Modules below) which are then called from React Query hooks.
Code:
app/src/api/axiosClient.ts
Session & Token Management
NextAuth manages the access token lifecycle. Developers do not need to handle token expiration manually.
Token Lifecycle
Login → POST /api/user/auth/login (via NextAuth authorize callback) → Access token (1-hour TTL) + refresh token stored in JWT session
On each request: NextAuth JWT callback checks Date.now() >= accessTokenExpires → If expired: POST /api/user/auth/refresh → Success: new access token stored, session updated → Failure: tokenError set in session (user stays logged in but API calls fail)tokenError in the session means the refresh token has expired. The user is still “logged in” from NextAuth’s perspective but every API call will fail with 401. The axiosClient interceptor will then sign them out on the next API call. A TODO exists to make this automatic sign-out immediate — it is not yet implemented.
2FA Login
When the user’s account has 2FA enabled, the FastAPI login endpoint returns tfa_required: true instead of a token. NextAuth throws a TwoFactorRequiredError containing the 2FA type and method. The login page catches this error code and shows the OTP input.
Session Shape
The NextAuth session exposes:
session.accessToken // Bearer token for fetchWrappersession.refreshToken // Managed by NextAuth internallysession.userType // e.g., "surrogate", "admin", "ip"session.setup_status // { is_fully_setup, missing_fields }session.tokenError // Set if refresh failed — treat as logged-out stateCode:
app/src/auth.ts
API Client Modules
The client-side API calls are organized into domain modules under src/api/client/:
src/api/client/├── index.ts — re-exports all modules├── auth.ts — login, password reset, OTP├── profile.ts — user profile, photo ID, profile picture├── case.ts — case list, case detail, messages, disbursements└── notifications.ts — push subscription managementImport them as namespaced modules:
import { caseApi, profileApi } from '@/api/client';
const cases = await caseApi.getCases(filters, sortBy);const profile = await profileApi.getProfile();All client modules use axiosClient internally. Never import axiosClient directly in components.
Code:
app/src/api/client/
React Query
All interactive data fetching uses React Query v3 (react-query). The QueryClient is initialized in RootProviders and wraps the entire app tree.
Query Keys
Query keys are defined in src/constants/index.ts as a structured object:
import { QueryKeys } from '@/constants';
QueryKeys.Profile.Base // 'profile'QueryKeys.Cases.Base // 'cases'QueryKeys.Cases.Details // 'case-details'QueryKeys.Disbursements.Base // 'disbursements'QueryKeys.Messages.Base // 'messages'Use QueryKeys.* constants — never write raw string keys in hooks or components.
Hook Pattern
All data-fetching hooks return a consistent [data, isLoading, errorMessage] tuple:
const query = useQuery(QueryKeys.Profile.Base, profileApi.getProfile, { refetchOnWindowFocus: false,});
return [ query.data || null, query.isLoading, (query.error as any)?.message || '',];Parameterized Queries
When query results depend on variables, include them in the key array:
useQuery( [QueryKeys.Cases.Base, filters, sortBy], (params) => caseApi.getCases( params.queryKey[1] as GetCaseFilters, params.queryKey[2] as string ), { refetchOnWindowFocus: false },);React Query re-runs the query whenever the key array changes — no manual effect needed.
Mutations & Cache Invalidation
After a mutation succeeds, invalidate the relevant query key to trigger a re-fetch:
const createMessage = useMutation( (values: MessageFormValues) => caseApi.createMessage(values), { onSuccess: () => { queryClient.invalidateQueries([QueryKeys.Messages.Base]); toast({ title: 'Success', description: 'Message sent' }); }, onError: (error: AxiosError<{ message: string }>) => { const errorMessage = error.response?.data?.message || error.message; toast({ title: 'Error', description: errorMessage, variant: 'destructive' }); }, },);For optimistic updates (immediate UI feedback without waiting for re-fetch), use queryClient.setQueryData():
queryClient.setQueryData<MeResponse | undefined>(QueryKeys.Profile.Base, (oldData) => { if (!oldData) return oldData; return { ...oldData, profile_picture: newPictureUrl };});S3 Direct Uploads
Files are never uploaded through the API server. The app requests a presigned URL from the backend, then uploads the file directly to S3.
Upload Flow
1. POST /api/.../upload-url { filename, content_type } → Response: { upload_url, upload_fields, file_key }
2. Build FormData: - Spread all upload_fields (S3 policy fields) - Append the file last
3. POST to upload_url (S3 endpoint, no auth header) → Check response.ok; throw on failure
4. POST /api/.../confirm { file_key } → Backend records the upload and returns updated dataExample (profile picture upload):
// Step 1: Get presigned URLconst { data: urlData } = await axios.post('/api/user/me/profile-picture/upload-url', { filename: file.name, content_type: file.type,});
// Step 2: Build FormDataconst formData = new FormData();Object.entries(urlData.upload_fields).forEach(([key, value]) => { formData.append(key, value as string);});formData.append('file', file);
// Step 3: Upload to S3const uploadResponse = await fetch(urlData.upload_url, { method: 'POST', body: formData });if (!uploadResponse.ok) { throw new Error(`S3 upload failed: ${uploadResponse.statusText}`);}
// Step 4: Confirmconst { data } = await axios.post('/api/user/me/profile-picture/confirm', { file_key: urlData.file_key,});The confirm step is required — the backend does not poll S3 for uploads. If you skip it, the file exists in S3 but the database has no record of it.
Code:
app/src/api/client/profile.ts
Error Handling
| Layer | Error | What Happens |
|---|---|---|
axiosClient | 401 response | PostHog reset + signOut() called |
fetchWrapper | 401 response | redirect('/login') called server-side |
fetchWrapper | Other 4xx/5xx | Response returned, caller must check response.ok |
| React Query | Any error | error field set in query result; hook returns errorMessage string |
| Mutations | API error | onError callback — extract error.response?.data?.message |
| S3 upload | Non-OK response | Read response.text() for AWS error XML; throw descriptive error |
Best practice for mutation error messages:
const errorMessage = (error as AxiosError<{ message: string }>).response?.data?.message || (error as Error).message;This covers both API error responses (which include a message field) and network-level errors.
Middleware & Route Protection
app/src/middleware.ts uses NextAuth’s built-in auth middleware. It runs on every request except:
/api/...routes/_next/static/.../_next/image/.../favicon.ico
Any request without a valid NextAuth session is redirected to /login. No additional route-level auth checks are needed in page components.
Code:
app/src/middleware.ts
Gotchas for Developers
Never use axiosClient in Server Components. It has no access to the server-side session. Use fetchWrapper instead.
Never use fetchWrapper in Client Components. It is marked 'use server' and will not work in a browser context.
React Query does not know about NextAuth session updates. If you update the session (e.g., via update({ name: newName })), you must also call queryClient.setQueryData() or queryClient.invalidateQueries() to keep UI state in sync.
The upload_fields must be spread before the file in S3 FormData. S3 processes the policy fields in order and will reject the upload if the file appears before the policy fields.
Open Questions
1. Axios vs fetch for presigned URL uploads
The S3 direct upload in profile.ts uses native fetch() rather than axiosClient to POST to S3. This is correct (S3 presigned URLs don’t need auth headers), but a comment explaining why would prevent future developers from switching to axiosClient and inadvertently adding auth headers.
2. React Query version
The app uses React Query v3 (react-query). Do not invest in a major React Query upgrade unless it is required to keep the current PWA stable; the strategic direction is to move product ownership into Flask rather than expand the Next.js surface.