Skip to Content
API Integration

@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

PathPurpose
@numa/api/helperscreateQueryGroup, createMutationGroup, endpoint helpers, list-cache utilities
@numa/api/query-clientcreateQueryClient, default stale/retry options
@numa/api/axios-instanceShared apiClient
@numa/api/services/<domain>Per-domain hooks (e.g. clients, auth)

Architecture overview

The API layer is built on three libraries:

LibraryRole
AxiosHTTP client — requests, interceptors, auth headers, token refresh
TanStack React QueryServer-state — caching, deduplication, retries, loading/error states
cookies-jsToken storage — persists access & refresh tokens in cookies
Component → useGetClientsQuery() // auto-generated hook → React Query (cache layer) → Axios (HTTP layer) → API server

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

  1. Base URL from NEXT_PUBLIC_API_BASE_URL
  2. Request interceptor — reads access token from cookies, attaches Authorization: Bearer <token>
  3. Response interceptor — catches 401, attempts silent token refresh
  4. setTokens / clearTokens — exported for auth hooks

Token refresh flow

When a request returns 401:

  1. No refresh token → error passes through
  2. Refresh already in flight → request queued, retried after refresh
  3. Otherwise → POST /refresh with refresh token, stores new tokens, retries original request
  4. Refresh fails → cookies cleared, user redirected to /

Layer 2 — Query client

File: packages/api/query-client.ts

OptionValueDescription
staleTime5 minutesData is fresh for 5 min — no refetch on mount
retry (queries)2Failed queries retry twice
retry (mutations)1Failed mutations retry once
refetchOnWindowFocusfalseTab 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 infinite pages array)
  • isLoadingMore — alias for isFetchingNextPage
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: createClientuseCreateClientMutation.

Variable shapes

GenericsCall shape
body onlymutate({ body: ... })
params onlymutate({ params: ... })
body + paramsmutate({ body, params })
voidmutate()

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

  1. removeFromPaginatedList — delete: remove row by id
  2. addCreatedToPaginatedList — create: insert row
  3. mergeUpdatedInPaginatedList — 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

PathDomain
services/auth.service.tsAuthentication
services/user.service.tsUser profile (/me)
services/iam/IAM (accounts, auth)
services/energy/Energy domain (clients, stations, chargers, sessions, meters)

Creating a new service

See Adding an API Service.

Pitfalls

SymptomCauseFix
List cache never updates after mutationWrong listQueryKeyAlign with createQueryGroup key
Duplicates after “Load more”Missing uniqueByEnsure items have id or set custom key
Create inserts nothingselectListItem returns non-objectReturn full object with id
Hook onSuccess not running aloneCache helpers run before your callbackExpected behavior — they run first
Last updated on