← All posts
Frontend8 min readProject: Auth admin panel

A React Query structure where one invalidation refreshes everything that changed

Small admin apps become messy fast: URLs typed everywhere, axios calls inside components, and stale screens after a save. Here is the structure I used for the auth admin panel: single sources of truth for endpoints, routes, query keys and config; hierarchical query keys so one invalidation refreshes a whole subtree; and one mutation wrapper for every write.

#React#TanStack Query#Next.js#TypeScript#Architecture#Axios

The problem

In a data-heavy admin panel, scattered URLs, axios calls inside components and hand-picked cache invalidation led to stale screens and inconsistent error handling.

The result

Every URL, key and setting has one home, every write behaves the same way, and one invalidation refreshes everything a change affects.

Stack

Next.jsReactTypeScriptTanStack QueryAxios

The problem

The auth admin panel is a classic data-heavy internal app: applications, their permission catalogs, groups, permissions per (group, app), members, users, service tokens. Almost every screen reads several of those, and almost every action changes several of them.

Apps like this tend to decay in the same ways:

  • URLs typed by hand in many files: /applications/${id}/groups, slightly different in each place, until one is wrong.
  • axios calls inside components, each with its own loading and error handling.
  • Stale screens after a save: you activate a group for an app, go back to the app page, and the group count hasn't changed until you refresh.
  • Inconsistent feedback: some actions show a toast, some don't, some show the backend's error, some show "Something went wrong".

The fix wasn't a library. It was a few strict rules about where things live.

The details

The rule: one source of truth, one job per file

src/
  lib/
    config.ts              the only file that reads process.env
    routes.ts              every frontend path
    api/
      endpoints.ts         every backend path
      client.ts            the one axios instance
      errors.ts            apiError() + the HTTP statuses this API uses
      query-keys.ts        every React Query key
      use-api-mutation.ts  the one mutation wrapper
  features/<feature>/
    api.ts                 the only place axios is called
    queries.ts             the only place components get data
    components/            the screens
  app/                     routes only: every page is a few lines

And the rules that keep it that way:

  1. No URL is written by hand. Backend paths come from endpoints.ts; frontend paths from routes.ts.
  2. Components never import axios. They call hooks from features/*/queries.ts.
  3. Every write goes through useApiMutation.
  4. Query keys are hierarchical.
  5. Nothing reads process.env except config.ts.

The challenges

  • Invalidation is the hard part of client caching. After a write, which cached queries are now wrong? Listing them by hand in every mutation is fragile.
  • Real APIs aren't perfectly consistent. Responses come back as { message, data }, { data, total, page, limit }, { authenticated, user } or { ok }.
  • Some errors should be toasts, some inline. A failed field validation belongs under the field; a failed delete belongs in a toast.
  • Configuration mistakes should fail loudly. A panel pointing at the wrong API is worse than a panel that doesn't start.

The solution

1. Every backend path in one file

export const endpoints = {
  applications: {
    root: '/applications',
    byId: (appId: number) => `/applications/${appId}`,
    syncCatalog: (appId: number) => `/applications/${appId}/sync-catalog`,
    restore: (appId: number) => `/applications/${appId}/restore`,
    hardDelete: (appId: number) => `/applications/${appId}/hard`,
  },
  groups: {
    root: '/groups',
    byId: (groupId: number) => `/groups/${groupId}`,
    /** One activation: POST creates it, DELETE revokes it. */
    app: (groupId: number, appId: number) => `/groups/${groupId}/apps/${appId}`,
    appPermissions: (groupId: number, appId: number) => `/groups/${groupId}/apps/${appId}/permissions`,
  },
  // …
};

Functions for parameterized paths mean a typo is a TypeScript error, not a 404 in production. The file also became the best documentation of the API's shape, with comments explaining choices like "groups are global, so they're no longer nested under an application".

The same idea applies to frontend paths in routes.ts, so every <Link> uses routes.appGroup(appId, groupId) instead of a hand-built string.

2. Hierarchical query keys

This is the heart of the structure:

export const queryKeys = {
  applications: ['applications'] as const,
  applicationList: (status: Status) => ['applications', 'list', status] as const,
  application: (appId: number) => ['applications', appId] as const,
  catalog: (appId: number) => ['applications', appId, 'catalog'] as const,
  serviceTokens: (appId: number) => ['applications', appId, 'service-tokens'] as const,

  groups: ['groups'] as const,
  group: (groupId: number) => ['groups', groupId] as const,
  groupAppPermissions: (groupId: number, appId: number) =>
    ['groups', groupId, 'apps', appId, 'permissions'] as const,
  groupMembers: (groupId: number) => ['groups', groupId, 'members'] as const,
};

TanStack Query matches keys by prefix. Invalidating ['applications', 1] also invalidates every key that starts with it:

['applications', 1]                      ✔ invalidated
['applications', 1, 'catalog']           ✔ invalidated
['applications', 1, 'service-tokens']    ✔ invalidated
['applications', 2, 'catalog']           ✘ untouched
['groups', 4]                            ✘ untouched

So the shape of the keys mirrors the shape of the data, and invalidation becomes a statement about what changed, not a list of every screen that might show it:

  • syncing app 1's catalog → invalidate application(1) → its catalog, its counts and its tokens all refetch,
  • disabling an app → invalidate applications → every list tab and every app detail refetches,
  • editing a group → invalidate group(4) → its members and its per-app permissions refetch.

The keys also encode real design decisions, and the comments say so:

  • "Groups are global, so their keys start at the root, not under an app."
  • "Tokens live under their application, so disabling an app clears them too."
  • "The one key that is per (group, app): permissions differ per application."

3. api.ts: the only place axios is called

Each feature has a tiny API file that knows the endpoint and the exact response shape of each call:

export async function fetchApplication(appId: number): Promise<Application> {
  const res = await api.get<ApiEnvelope<Application>>(endpoints.applications.byId(appId));
  return res.data.data;
}

export async function fetchUsers(query: UsersQuery): Promise<Paginated<User>> {
  const res = await api.get<Paginated<User>>(endpoints.users.root, { params: query });
  return res.data; // { data, total, page, limit }: different envelope
}

There's deliberately no generic unwrap(). The API's envelopes aren't consistent, and a "smart" helper that guesses where the data is would hide those differences until one day it guesses wrong. Typing each endpoint on its own is a few extra lines and zero surprises.

4. queries.ts: the only place components get data

export function useApplication(appId: number) {
  return useQuery({
    queryKey: queryKeys.application(appId),
    queryFn: () => fetchApplication(appId),
    enabled: Number.isFinite(appId),
  });
}

Components only ever see hooks. They don't know about axios, endpoints or keys. Changing how something is fetched touches one file.

Defaults are chosen to make the common case automatic. For example, useApplications() defaults to status = 'active', so every picker, the sidebar and the command palette ignore disabled apps without anyone remembering to filter them.

5. One mutation wrapper for every write

export function useApiMutation<TData, TVariables = void>({
  mutationFn,
  successMessage,
  invalidates,
  errorToast = true,
  onSuccess,
  onError,
}: ApiMutationOptions<TData, TVariables>) {
  const queryClient = useQueryClient();
  return useMutation<TData, unknown, TVariables>({
    mutationFn,
    onSuccess: (data, variables) => {
      for (const queryKey of invalidates?.(data, variables) ?? []) {
        void queryClient.invalidateQueries({ queryKey });
      }
      if (successMessage) {
        toast.success(typeof successMessage === 'function' ? successMessage(data, variables) : successMessage);
      }
      onSuccess?.(data, variables);
    },
    onError: (error, variables) => {
      if (errorToast) toast.error(apiError(error)); // the backend's own message
      onError?.(error, variables);
    },
  });
}

Every write gets the same three things for free: a success toast, cache invalidation and the backend's real error message. A feature mutation becomes a short declaration:

export function useSyncCatalog(appId: number) {
  return useApiMutation({
    mutationFn: () => syncCatalog(appId),
    successMessage: (entries) => `Synced ${entries.length} permissions.`,
    invalidates: () => [queryKeys.application(appId)],
    errorToast: false, // the screen shows an inline error card (the app may be down: 424)
  });
}

The options exist for the real exceptions:

  • errorToast: false when the screen shows the error inline (a form field, an error card),
  • no successMessage when something better follows, like the reveal-once API key dialog after registering an app.

6. Fail fast on configuration

function required(name: string, value: string | undefined): string {
  if (!value) throw new Error(`Missing environment variable: ${name}`);
  return value.replace(/\/$/, '');
}

config.ts is the only file that reads process.env, and it normalizes values (like trailing slashes) once. A missing or wrong API URL is caught at startup, not as a confusing failure on the first click.

7. Pages are just routes

With everything else in features, each app/ page is a few lines that render a screen component. Routing and product logic never mix, and moving a screen is trivial.

Lessons learned

  • Give every kind of value one home. Endpoints, routes, keys and config each live in exactly one file.
  • Mirror your data in your query keys. Hierarchical keys turn invalidation into "this thing changed" instead of "refresh these ten screens".
  • Encode design decisions in key shapes. Global groups at the root, tokens under their app: the cache follows the model.
  • Keep axios out of components. Feature api.ts + queries.ts files make data access predictable and replaceable.
  • Don't abstract away inconsistency. A generic unwrap over inconsistent envelopes hides bugs; explicit types reveal them.
  • One wrapper for all writes. Consistent toasts, invalidation and errors, with explicit opt-outs for real exceptions.
  • Choose defaults that make the common case automatic. "Active only" by default means nobody forgets to filter.
  • Fail fast on config. A panel that refuses to start is better than one pointing at the wrong API.

FAQ

How do hierarchical query keys work in TanStack Query?

Query keys are arrays, and invalidateQueries matches by prefix by default. If a key starts with ['applications', 1], invalidating that prefix refreshes it too. Designing keys as a tree lets one invalidation refresh everything under a resource.

Should React components call axios directly?

It's better not to. Put requests in small API modules and expose data through hooks. Components stay simple, and fetching, caching and error handling stay consistent and easy to change.

Is a generic API response unwrap helper a good idea?

Only if every endpoint really uses the same envelope. If responses differ (paginated, wrapped, plain), typing each endpoint explicitly avoids silent bugs where the helper picks the wrong field.

How do I show consistent success and error messages for mutations?

Wrap useMutation once with your toast, invalidation and error-message logic, and use that wrapper for every write. Allow explicit options for cases that need inline errors or no toast.

Facing a similar problem?

I help teams fix performance, backend and reliability issues in production.

Let's talk
Welcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have fun

Componentity

Thanks for visiting my portfolio. Every project here reflects my passion for building and learning. If you’re interested in working together, have a question, or just want to connect—don’t hesitate to reach out. I'm always open to meaningful conversations and new ideas.

© 2026 Zakariya Zouazou. All Rights Reserved.

Contact Us