Skip to content

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

ClientFileUsed InAuth Method
fetchWrappersrc/api/fetch.tsServer Components (initial page data)Reads session server-side, injects Bearer token
axiosClientsrc/api/axiosClient.tsClient 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: true to skip the Content-Type: application/json header
  • Cache control: by default responses are cached; pass cache: false for 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 fetchWrapper
session.refreshToken // Managed by NextAuth internally
session.userType // e.g., "surrogate", "admin", "ip"
session.setup_status // { is_fully_setup, missing_fields }
session.tokenError // Set if refresh failed — treat as logged-out state

Code: 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 management

Import 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:

useFetchSelf.ts
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 data

Example (profile picture upload):

// Step 1: Get presigned URL
const { data: urlData } = await axios.post('/api/user/me/profile-picture/upload-url', {
filename: file.name,
content_type: file.type,
});
// Step 2: Build FormData
const 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 S3
const uploadResponse = await fetch(urlData.upload_url, { method: 'POST', body: formData });
if (!uploadResponse.ok) {
throw new Error(`S3 upload failed: ${uploadResponse.statusText}`);
}
// Step 4: Confirm
const { 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

LayerErrorWhat Happens
axiosClient401 responsePostHog reset + signOut() called
fetchWrapper401 responseredirect('/login') called server-side
fetchWrapperOther 4xx/5xxResponse returned, caller must check response.ok
React QueryAny errorerror field set in query result; hook returns errorMessage string
MutationsAPI erroronError callback — extract error.response?.data?.message
S3 uploadNon-OK responseRead 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.