The web app has two data paths and they serve different data. Domain data is not duplicated into the query cache. A client view keeps what it received in local state and updates it from action results.

The QueryClient

modules/shared/lib/query-client.ts:
  • staleTime is one minute.
  • retry is off. A failed request surfaces at once.
  • Pending queries are dehydrated too, so a prefetch that has not finished can stream.
Two instances exist:

Hydration

Session and organization list

(authenticated)/layout.tsx prefetches on the server and dehydrates:
On the client, useSessionQuery() reads the same key with staleTime: Infinity, refetchOnWindowFocus: false and retry: false. The session is therefore fetched once per full page load and then updated by hand:
  • reloadSession() from useSession() refetches with disableCookieCache and writes the result with setQueryData.
  • LoginForm invalidates sessionQueryKey after a successful sign in.
  • setActiveOrganization() patches activeOrganizationId and lastActiveOrganizationId in place.

The active organization boundary

[organizationSlug]/layout.tsx prefetches the organization and wraps its subtree in a second HydrationBoundary:
The second boundary is required. React renders the parent layout first, so the parent’s dehydrate() snapshot is taken before the child layout has run its prefetch. Without a boundary of its own, the organization would be missing from the client cache on the first paint. That matters because NavBar gates every menu item except the home item on activeOrganization. With an empty cache the sidebar showed only the first item and filled in the rest after a client refetch. With the boundary the full menu is in the server HTML.
The general rule: with a request scoped server QueryClient, a parent layout’s dehydrated state never includes prefetches made by a child layout. Any layout or page that prefetches must render its own HydrationBoundary. admin/organizations/[id]/page.tsx does.
Three pages prefetch into the server QueryClient without a boundary of their own: [organizationSlug]/settings/billing/page.tsx and (account)/settings/billing/page.tsx (purchases), and (account)/settings/security/page.tsx (accounts and passkeys). Only three files in the app render a HydrationBoundary: the authenticated layout, the organization layout and the admin organization page. By the rule above those three prefetches do not reach the browser cache, and the client components fetch again after mount. The pages still work. The prefetch is just wasted.
useActiveOrganizationQuery sets placeholderData: (previous) => previous, so switching between two studios keeps the old one on screen until the new one arrives. ActiveOrganizationProvider treats placeholder data as not yet loaded when computing loaded.

Query keys

Hand written keys

Scope every domain key by slug. A user can belong to several studios and the cache outlives navigation between them. The food library key is shared on purpose. Editing a food in the foods page, changing MBP anchors in settings and editing inside the nutrition builder all call invalidateQueries({ queryKey: ["food-library", slug], refetchType: "all" }), so a stale copy never resurfaces in another screen.

oRPC keys

modules/shared/lib/orpc-query-utils.ts exports orpc, built with createTanstackQueryUtils(orpcClient). Each procedure gets queryOptions, mutationOptions and queryKey helpers:
Procedures the web app calls: The router type is modules/shared/lib/orpc-router.generated.d.ts. It is excluded from oxlint in .oxlintrc.json. No script in the frontend repo generates it, and a comment in modules/admin/lib/admin-api.ts says it is kept by hand and lags new procedures. When the backend adds a procedure, update this file before calling it. orpcClient throws if used on the server (RPCLink is not allowed on the server side). On the server use createServerOrpcClient() from bff-server-fetch.ts. The client logs errors through logError, except aborted requests and SERVICE_UNAVAILABLE.

Server side request caching

React cache() deduplicates within one request:
  • All helpers in modules/auth/lib/server.ts (getSession, getActiveOrganization, getOrganizationList and the rest).
  • listPurchases in modules/payments/lib/server.ts.
  • loadSigning in modules/sign/sign-upstream.ts.
This is why three layouts and a page can each call getSession() and only one request reaches the API. resolveStudioContext is not wrapped itself, but the two helpers it calls are. performApi calls are not deduplicated. getStudioAppearance(slug) is called by the organization layout and again by pages that need the brand colour, and each call reads /studios/current. No fetch in the app opts into the Next.js data cache. fetchAuthApi sets cache: "no-store" and the signed client uses default fetch behaviour inside dynamic routes.

Pagination and lists

Pickers that search trainees call a server action per keystroke (for example searchTrainees in the dashboard actions, pageSize: 6). They do not take a snapshot of the whole trainee list.

URL state

nuqs is mounted through NuqsAdapter in the root layout. SocialSigninButton reads invitationId with useQueryState. The trainee board stores its filter rules in ?rules= as JSON and its selection in ?client=. The plan lists use ?scope=training or ?scope=nutrition, and ?ai=1 opens the import wizard.