@barandurakk/query-api-router
v0.3.0
Published
Type-safe endpoint router helpers for TanStack Query.
Maintainers
Readme
Query API Router
Type-safe endpoint definitions on top of TanStack Query for clean, reusable, low-boilerplate data hooks.
Build API “routers” once, then use consistent useQuery, useMutation, useSuspenseQuery, useInfiniteQuery, getKey, getPrefixKey, and getOptions everywhere.
Why This Exists
TanStack Query is powerful, but large apps often drift into:
- Repeated query key strings
- Repeated
queryFnwiring - Inconsistent invalidation logic
- Loader/prefetch code that duplicates component query logic
- Hard-to-find endpoint behavior and defaults
Query API Router solves this by making endpoint definitions the single source of truth.
You define each endpoint once, and get:
- Reusable hooks
- Centralized keys
- Reusable query options for loaders/prefetch/fetchQuery
- Built-in mutation-driven invalidation
- Strong TypeScript inference
Key Features
- Endpoint router pattern: organize by domain (
users,orders,attachments, etc.) - Typed query/mutation/infinite-query definitions
- Generated hook API per endpoint
getKey()for stable key access and manual invalidationgetPrefixKey()for invalidating every state variant of a query endpointgetOptions()forensureQueryData,fetchQuery,useQueries, and non-React usage- TanStack
QueryFunctionContextaccess for cancellation, metadata, keys, and the active client - Inferred, router-level mutation invalidation with support for keys from any router
- Layered defaults:
- API-level defaults
- Endpoint-level defaults
- Call-site overrides
- Transport-agnostic (Axios, fetch, GraphQL client, anything async)
Installation
pnpm add @barandurakk/query-api-router @tanstack/react-query reactCompatibility
- TanStack React Query:
>=5.90.18 <6 - React:
>=18 - Package format: ESM
CI verifies the library on maintained Node.js releases and tests the packed artifact against both the exact minimum peer versions and the latest compatible TanStack Query v5/React release. The compatibility matrix also runs weekly so newly published compatible peer versions are exercised even when this repository has no new commits.
Quick Start
1) Create QueryClient
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
refetchOnWindowFocus: false,
},
},
});Pass this client to QueryClientProvider as usual. Generated mutations use that active client for invalidation unless the router explicitly supplies a different client.
2) Define an API router
import api from '@barandurakk/query-api-router';
import { apiClient } from './http'; // axios/fetch wrapper
type ApiError = unknown;
type UserProfile = { id: string; email: string; language: string };
type Response<T> = { data: T; success: boolean; message: string };
export const usersApi = api(
'users',
{
getProfile: api.query<Response<UserProfile>, void, ApiError>({
queryFn: async () => (await apiClient.get('/users/profile')).data,
}),
updateLanguage: api.mutation<Response<{ language: string }>, { language: string }, ApiError>({
mutationFn: async (body) => (await apiClient.put('/users/profile/language', body)).data,
}),
},
{
invalidations: {
updateLanguage: (router) => [router.getProfile.getKey()],
},
queryOptions: {
staleTime: 60_000,
},
}
);3) Use in components
function ProfileSection() {
const { data, isLoading } = usersApi.getProfile.useQuery();
const { mutateAsync: updateLanguage, isPending } = usersApi.updateLanguage.useMutation();
if (isLoading) return <div>Loading...</div>;
return (
<button
disabled={isPending}
onClick={() => updateLanguage({ language: 'ru' })}
>
Current language: {data?.data.language}
</button>
);
}Core Concepts
1) API Router
api(baseKey, endpoints, options?)
baseKeybecomes the root query key namespace (example:'orders')endpointsis an object ofquery,mutation,infiniteQueryoptionscan provide:- inferred mutation
invalidations queryClient(optional explicit invalidation-client override)- shared
queryOptions - shared
mutationOptions
- inferred mutation
Mutation invalidation uses the QueryClient from the active mutation context by default. Supply queryClient only when invalidation intentionally needs to target a different client.
Shared defaults use TanStack Query's default-option contracts, so misspelled or unsupported option names fail TypeScript checks.
Canonical placement for mutation behavior:
- Put the request function and reusable TanStack mutation options inside
api.mutation(...). - Put cache effects inside the outer router's
invalidationsmap, keyed by mutation name. - Build invalidation targets with router-owned key helpers instead of repeating key arrays.
2) Endpoint Definition Types
api.query({ queryFn, options? })api.mutation({ mutationFn, options? })api.infiniteQuery({ queryFn, options })
3) Generated Endpoint API
For a query endpoint:
useQuery(state?, options?)useSuspenseQuery(state?, options?)getKey(state?)getPrefixKey()getOptions(state?)
For a mutation endpoint:
useMutation(options?)getKey()
For an infiniteQuery endpoint:
useInfiniteQuery(state?, options?)getKey(state?)getPrefixKey()getOptions(state?)
4) Automatic Invalidation
Declare invalidation next to the router, after all endpoints are known. The callback receives the fully inferred current router, so endpoint names and state arguments remain type-safe without a handwritten interface.
import api from '@barandurakk/query-api-router';
// This router may live in another module.
export const customersApi = api('customers', {
getById: api.query<Response<Customer>, { customerId: string }, ApiError>({
queryFn: async ({ customerId }) =>
(await apiClient.get(`/customers/${customerId}`)).data,
}),
});
export const ordersApi = api(
'orders',
{
list: api.query<Response<Order[]>, { page: number; status?: string }, ApiError>({
queryFn: async (filters) =>
(await apiClient.get('/orders', { params: filters })).data,
}),
myOrders: api.query<Response<Order[]>, void, ApiError>({
queryFn: async () => (await apiClient.get('/orders/mine')).data,
}),
getById: api.query<Response<Order>, { orderId: string }, ApiError>({
queryFn: async ({ orderId }) =>
(await apiClient.get(`/orders/${orderId}`)).data,
}),
createOrder: api.mutation<Response<Order>, { body: CreateOrder }, ApiError>({
mutationFn: async ({ body }) =>
(await apiClient.post('/orders', body)).data,
}),
},
{
invalidations: {
createOrder: (orders, _variables, result, _onMutateResult, _context) => [
// Keys owned by the current router:
// Every filtered or paginated list variant:
orders.list.getPrefixKey(),
orders.myOrders.getKey(),
// One state-specific query variant:
orders.getById.getKey({ orderId: result.data.id }),
// Keys owned by another router work too:
customersApi.getById.getKey({
customerId: result.data.customerId,
}),
],
},
},
);Each invalidation callback receives:
- The fully inferred current router
- Mutation variables
- Mutation result data
- The value returned by
onMutate - TanStack Query's
MutationFunctionContext
Return either raw QueryKey values or complete InvalidateQueryFilters objects. Every target is invalidated on the mutation's active QueryClient, and all invalidations finish before API-default, endpoint, and call-site success callbacks run.
Cross-router keys therefore work when the routers share a cache. Referencing another router does not switch to that router's configured client; use an explicit client only when intentionally targeting a different cache.
The older endpoint-local invalidateQueries callback remains supported for compatibility. Prefer the router-level invalidations map for new code because it provides complete inference without a separate router interface. If both forms are supplied for one mutation, the router-level callback takes precedence.
5) Option Layering (important)
Final options are merged in this order:
- API defaults
- Endpoint options
- Call-site options
This gives global consistency with local flexibility.
Call sites may intentionally override a generated query key:
const query = usersApi.getProfile.useQuery(undefined, {
queryKey: ['users', 'profile', 'embedded-view'],
});getKey() still returns the canonical endpoint key. When a call site uses a different key, that call site owns invalidation of the alternate key.
Real Usage Patterns
Pattern: Router Loaders / Prefetch
Use the exact same endpoint definition in route loaders:
await queryClient.ensureQueryData(
ordersApi.getById.getOptions({ orderId })
);No duplicate keys. No duplicate fetch logic.
Pattern: Suspense
const { data } = ordersApi.getById.useSuspenseQuery({ orderId });Pattern: Query cancellation and context
Standard query functions receive state first and TanStack's QueryFunctionContext second:
download: api.query<Blob, { documentId: string }, ApiError>({
queryFn: async ({ documentId }, { signal }) => {
const response = await fetch(`/documents/${documentId}`, { signal });
return response.blob();
},
});Existing query functions that only need state may omit the second parameter.
Pattern: useQueries composition
const results = useQueries({
queries: [
ordersApi.myOrders.getOptions({ page: 1, limit: 100 }),
ticketApi.myTickets.getOptions({ page: 1, limit: 100 }),
usersApi.getProfile.getOptions(),
],
});Pattern: Dynamic endpoint selection
When context decides which endpoint to call, compose with getOptions():
const options =
context === 'invoice'
? invoicesApi.getDownloadUrl.getOptions({ invoiceId: id })
: attachmentsApi.getDownloadUrl.getOptions({ attachmentId: id });
const result = await queryClient.fetchQuery({ ...options, staleTime: 0 });Pattern: Infinite query endpoint definition
const addressesApi = api(
'addresses',
{
infiniteList: api.infiniteQuery<Paginated<Address[]>, void, number, ApiError>({
queryFn: async (_state, { pageParam }) =>
(await apiClient.get(`/account/addresses?page=${pageParam}&limit=10`)).data,
options: {
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.pagination.totalPages > lastPage.pagination.page
? lastPage.pagination.page + 1
: undefined,
},
}),
},
);Usage:
const listQuery = addressesApi.infiniteList.useInfiniteQuery();Required pagination behavior lives with the endpoint so getOptions() is also complete. Components can still provide partial overrides as the second argument.
Pattern: Manual invalidation when needed
You still have full QueryClient control. Choose the narrowest router-owned key for the cache scope you want:
// One canonical state-specific query variant: ['orders', 'getById', { orderId }]
await queryClient.invalidateQueries({
queryKey: ordersApi.getById.getKey({ orderId }),
exact: true,
});
// Every state variant of one query endpoint: ['orders', 'list']
await queryClient.invalidateQueries({
queryKey: ordersApi.list.getPrefixKey(),
});
// Every query endpoint in the router: ['orders']
await queryClient.invalidateQueries({
queryKey: ordersApi.getKey(),
});getKey(state) remains state-safe: endpoints with required state still require it. getPrefixKey() takes no state and is available only on query and infinite-query endpoints. Mutation endpoints keep their existing mutation getKey() helper.
API Reference
api(baseKey, endpoints, options?)
baseKey: stringendpoints: Record<string, EndpointDefinition>options?: { invalidations?: ApiInvalidations<TBaseKey, TEndpoints>; queryClient?: QueryClient; queryOptions?: ApiQueryOptions; mutationOptions?: ApiMutationOptions }
invalidations is keyed by mutation endpoint name. Each callback is inferred from the complete router and the selected mutation definition:
{
invalidations: {
mutationName: (router, variables, data, onMutateResult, context) => [
router.someQuery.getKey(),
],
},
}Query endpoint names are rejected as invalidations entries. Callback targets may also use imported routers or full TanStack InvalidateQueryFilters objects.
Returns an API instance with:
getKey(): QueryKeyfor base namespace- query and infinite-query endpoints with
getKey(state?),getPrefixKey(), and their generated option/hook helpers - mutation endpoints with their existing
getKey()anduseMutation()helpers
api.query<TQueryFnData, TState, TError, TData = TQueryFnData>(definition)
definition:
queryFn: (state: TState, context: QueryFunctionContext) => Promise<TQueryFnData>options?: UseQueryOptions without queryKey/queryFn
api.mutation<TData, TVariables, TError, TApi = InvalidationApi, TOnMutateResult = unknown>(definition)
definition:
mutationFn: (variables: TVariables) => Promise<TData>options?: UseMutationOptions without mutationFninvalidateQueries?: legacy endpoint-local invalidation callback
For new code, put invalidation in the outer router's invalidations map and normally specify only TData, TVariables, and TError. The TApi parameter remains available for backward-compatible endpoint-local callbacks, where its contract must still be supplied explicitly.
api.infiniteQuery<TQueryFnData, TState, TPageParam, TError, TData = InfiniteData<TQueryFnData, TPageParam>>(definition)
definition:
queryFn: (state: TState, ctx: QueryFunctionContext) => Promise<TQueryFnData>options: UseInfiniteQueryOptions without queryKey/queryFn
options must include initialPageParam and getNextPageParam. This guarantees that both the generated hook and getOptions() are valid TanStack infinite-query configurations.
Why Teams Like It
- Less boilerplate than raw hooks per endpoint
- Better consistency across large codebases
- Easy discoverability: “all order endpoints are in
ordersApi” - Shared patterns for components, loaders, and utilities
- Safer refactors with central key ownership
- Easy onboarding for new team members
Before vs After
Raw React Query style:
useQuery({
queryKey: ['orders', 'getById', { orderId }],
queryFn: () => apiClient.get(`/orders/${orderId}`).then((r) => r.data),
});With Query API Router:
ordersApi.getById.useQuery({ orderId });And for loaders:
await queryClient.ensureQueryData(ordersApi.getById.getOptions({ orderId }));One endpoint definition, reused everywhere.
Best Practices
- Keep one router per domain (
usersApi,ordersApi,ticketApi) - Use descriptive endpoint names (
getById,list,update,activate) - Keep
stateobjects serializable and stable - Put cache policy (
staleTime,gcTime, polling) at endpoint level - Prefer the router-level
invalidationsmap over scattered component invalidation - Use
getOptions()for non-hook contexts (loaders, utility flows, background tasks) - Use API-level defaults for cross-cutting behavior (retry/error handling)
FAQ
Does this replace TanStack Query?
No. It is a structured layer on top of TanStack Query.
Can I still use QueryClient directly?
Yes. Fully compatible. getKey(), getPrefixKey(), and getOptions() are designed for that.
Can I use it without Axios?
Yes. Axios is not a dependency or peer dependency. Any async client works, and applications define their own error types.
Does it support Suspense and Infinite Query?
Yes. useSuspenseQuery and useInfiniteQuery are first-class.
Is this good for large apps?
Yes, that is the primary use case.
Package and License
The package publishes ESM JavaScript, TypeScript declarations, declaration maps, runtime source maps, and their TypeScript sources. Runtime maps embed their source content, while declaration maps resolve to the shipped src/ tree. This keeps debugging and editor navigation working from the installed package; the export map still prevents unsupported source-level imports.
Query API Router is available under the MIT License.
