@numa/api
HTTP client (Axios), TanStack Query hook factories, and React Query cache helpers. Domain endpoints live under services/ (e.g. @numa/api/services/energy).
Imports
| Path | Purpose |
|---|---|
@numa/api/helpers | createQueryGroup, createMutationGroup, endpoint helpers, list-cache utilities |
@numa/api/query-client | createQueryClient, default stale/retry options |
@numa/api/axios-instance | Shared apiClient |
@numa/api/services/<domain> | Per-domain hooks (e.g. clients, auth) |
Architecture overview
The API layer is built on three libraries:
| Library | Role |
|---|---|
| Axios | HTTP client — requests, interceptors, auth headers, token refresh |
| TanStack React Query | Server-state — caching, deduplication, retries, loading/error states |
| cookies-js | Token storage — persists access & refresh tokens in cookies |
Component
→ useGetClientsQuery() // auto-generated hook
→ React Query (cache layer)
→ Axios (HTTP layer)
→ API serverYou never call Axios directly. Define endpoints once in a service file, get auto-generated hooks, and use those hooks in components.
Layer 1 — Axios instance
File: packages/api/axios-instance.ts
Creates a single Axios instance with:
- Base URL from
NEXT_PUBLIC_API_BASE_URL - Request interceptor — reads access token from cookies, attaches
Authorization: Bearer <token> - Response interceptor — catches
401, attempts silent token refresh setTokens/clearTokens— exported for auth hooks
Token refresh flow
When a request returns 401:
- No refresh token → error passes through
- Refresh already in flight → request queued, retried after refresh
- Otherwise →
POST /refreshwith refresh token, stores new tokens, retries original request - Refresh fails → cookies cleared, user redirected to
/
Layer 2 — Query client
File: packages/api/query-client.ts
| Option | Value | Description |
|---|---|---|
staleTime | 5 minutes | Data is fresh for 5 min — no refetch on mount |
retry (queries) | 2 | Failed queries retry twice |
retry (mutations) | 1 | Failed mutations retry once |
refetchOnWindowFocus | false | Tab switching doesn’t trigger refetches |
The provider (@numa/providers/rq.tsx) wraps the app in QueryClientProvider using a singleton on client and a fresh instance per SSR request.
Layer 3 — Hook generators
File: packages/api/helpers.ts
queryEndpoint — single request
For one-shot GETs (detail views, non-paginated lists):
export const queries = createQueryGroup({
getClient: queryEndpoint<TClient, { id: string }>({
url: ({ id }) => `/energy/clients/${id}`,
}),
});const { data, isLoading, error, refetch } = useGetClientQuery({ id: "abc" });paginatedQueryEndpoint — cursor + infinite scroll
For paginated lists. The API must return TPaginatedRes<TItem[]>: { items, hasNext, nextCursor, ... }.
The generated hook uses useInfiniteQuery and exposes:
data— flattened shape{ items, hasNext, nextCursor, rawRes }(not raw infinitepagesarray)isLoadingMore— alias forisFetchingNextPage
export const queries = createQueryGroup({
getClients: paginatedQueryEndpoint<TClient, TClientsListParams>({
url: "/energy/clients",
paginated: {
uniqueBy: "id",
},
}),
});const { data, fetchNextPage, isLoadingMore, hasNextPage } = useGetClientsQuery(listParams);
const rows = data?.items ?? [];Query keys
- Paginated:
[endpointKey, params, "paginated", VAR_DEFAULT_PAGE_SIZE, limitParam] - Non-paginated:
[endpointKey, params]
Changing params changes the key → separate cache entries.
mutationEndpoint — write operations
export const mutations = createMutationGroup({
createClient: mutationEndpoint<TClient, TClientCreateReq>({
url: "/energy/clients",
method: "POST",
}),
});Hook names are auto-generated: createClient → useCreateClientMutation.
Variable shapes
| Generics | Call shape |
|---|---|
| body only | mutate({ body: ... }) |
| params only | mutate({ params: ... }) |
| body + params | mutate({ body, params }) |
| void | mutate() |
List cache integration
Mutation endpoints can automatically update paginated list caches without refetching:
createClient: mutationEndpoint<TClient, TClientCreateReq>({
url: "/energy/clients",
method: "POST",
addCreatedToPaginatedList: {
listQueryKey: "getClients",
selectListItem: (data) => data,
},
}),
deleteClient: mutationEndpoint<void, void, { id: string }>({
url: ({ id }) => `/energy/clients/${id}`,
method: "DELETE",
removeFromPaginatedList: {
listQueryKey: "getClients",
getDeletedId: (variables) => (variables as { params: { id: string } }).params.id,
},
}),Run order (before your hook onSuccess):
removeFromPaginatedList— delete: remove row by idaddCreatedToPaginatedList— create: insert rowmergeUpdatedInPaginatedList— update: replace row by id
uniqueBy (deduplication)
When multiple pages are merged, rows are deduped by uniqueBy (default "id"). First occurrence wins. Set paginated: { uniqueBy: false } to disable.
Layer 4 — Services
Services live in packages/api/services/. Each file represents one API domain.
Available services
| Path | Domain |
|---|---|
services/auth.service.ts | Authentication |
services/user.service.ts | User profile (/me) |
services/iam/ | IAM (accounts, auth) |
services/energy/ | Energy domain (clients, stations, chargers, sessions, meters) |
Creating a new service
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| List cache never updates after mutation | Wrong listQueryKey | Align with createQueryGroup key |
| Duplicates after “Load more” | Missing uniqueBy | Ensure items have id or set custom key |
| Create inserts nothing | selectListItem returns non-object | Return full object with id |
Hook onSuccess not running alone | Cache helpers run before your callback | Expected behavior — they run first |