React & Next.js Frontend Architecture: How We Implement It
William Warne
Software Engineer | Fractional CTO | Founder
Introduction
In Frontend architecture patterns that work in any stack we laid out tool-agnostic ideas: two kinds of state, single source of truth, API layer, centralised cache keys, stateless components, optimistic updates, and validation at the boundary. Here we show how we implement those with React Query, React context, and Next.js—and how we keep the rules consistent so AI and developers produce the same structure. This is what our Frontend Design & Architecture pack encodes (Pack 04).
Server State: React Query Only
All server-backed data goes through React Query. We put query and mutation hooks in a clientDomains folder: one folder per domain (e.g. account, message, document). Each domain has an api.ts (all API calls), query hooks (e.g. useMessages(accountId)), and mutation hooks (e.g. useSendMessage). Components never call the API directly; they use these hooks.
We don’t use useEffect to fetch or sync server data. React Query handles caching, background refetch, loading, and error. We never keep a duplicate copy of server data in component state or context—so there’s a single source of truth per domain.
Source vs subset hooks: We define one “source” query (e.g. messages by account ID). Other views (e.g. messages by phone) can be a subset or derived view that reads from the same query key (or a related one) so we don’t duplicate fetches or cache entries.
UI State: React Context Only
UI-only state lives in React contexts in app/contexts/: modals, panels, selected tab, onboarding step, auth display state. We never put server data or API calls in context. If a component needs server data, it uses a React Query hook; if it only needs “is this panel open?”, that comes from context. Clear split: server state in React Query, UI state in context.
API Layer and Query Keys
API layer — Every server call goes through clientDomains/{domain}/api.ts. We use a shared HTTP client (e.g. Axios) with base URL, auth, and error mapping in one place. Components and contexts never import the client or call endpoints directly.
Query keys — All React Query keys live in constants/queryKeys.ts. We use an object with small functions for parameterised keys (e.g. queryKeys.messages.byAccountId(accountId)). That way invalidation and cache updates use the same keys everywhere, and we can add “subset” hooks that share the cache without duplicate requests.
Components and Data Boundaries
Components are stateless in the sense that they don’t own server data—they get it via props or from React Query hooks. We put the hook at the boundary where the data is first needed (e.g. a list component that calls useMessages(accountId)). That component handles isLoading and error and renders loading or error UI; child components receive data via props. Single responsibility and easy to test.
Mutations, Optimistic Updates, and Realtime
Mutations — We use React Query’s useMutation with optimistic updates: in onMutate we snapshot the previous cache, update the cache immediately, and return the snapshot. On failure, onError restores from the snapshot. In onSettled we invalidate with refetchType: 'none' so we don’t trigger an automatic refetch and cause a flicker—we’ve already updated the cache.
Realtime — For WebSockets or SSE we use a dedicated hook (e.g. in the same clientDomain) that subscribes and writes normalised data into the same React Query cache that the read hooks use. We always clean up the subscription on unmount so we don’t leak or overwrite after the component is gone.
Validation and Error Handling
We validate at the API boundary with Zod. We use separate schemas for CREATE vs UPDATE when nested entities differ (e.g. new nested items don’t have IDs; updates might). That avoids sending invalid payloads and keeps types and runtime in sync.
Errors are mapped in the API layer (e.g. Axios response interceptor) to a consistent shape and user-facing messages. Components use React Query’s error and isError and show them at the same boundary where they use the data (e.g. inline under the list or a small banner). We don’t expose raw server messages or stack traces to the UI.
File Structure and Next.js
Layout:
clientDomains/{domain}/—api.ts, query hooks, mutation hooks, realtime hooks, normalisers, types.app/contexts/— UI-only context providers.constants/queryKeys.ts— All query keys.lib/— API client config, shared validation.
With Next.js App Router we use server components where we don’t need client state (e.g. static content, layout). For client interactivity we use client components that call our React Query hooks. We use middleware for auth when needed, and we set metadata (title, description, OG) per page or with generateMetadata for dynamic routes. For static lists (e.g. blog, pack pages) we use a single loader pattern and keep the same “server state in one place” idea—just that the source is build-time or server-fetched, not client React Query.
Conclusion
We implement the same tool-agnostic patterns with React Query (server state), React context (UI state), clientDomains and centralised query keys (API layer and cache), stateless components with data at the boundary, optimistic updates and realtime into the same cache, and Zod at the API boundary. That keeps our frontends consistent and makes it easy for AI and new devs to follow the same rules. If you want these standards in your repo—Cursor rules, full docs, and methodology—you can install our Frontend Design & Architecture pack and run ctx compile so the whole stack stays aligned.
Context as Code — Packs for frontend architecture, API design, and documentation. Install a pack, run compile, and keep your stack consistent.