npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@barandurakk/query-api-router

v0.3.0

Published

Type-safe endpoint router helpers for TanStack Query.

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 queryFn wiring
  • 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 invalidation
  • getPrefixKey() for invalidating every state variant of a query endpoint
  • getOptions() for ensureQueryData, fetchQuery, useQueries, and non-React usage
  • TanStack QueryFunctionContext access 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 react

Compatibility

  • 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?)

  • baseKey becomes the root query key namespace (example: 'orders')
  • endpoints is an object of query, mutation, infiniteQuery
  • options can provide:
    • inferred mutation invalidations
    • queryClient (optional explicit invalidation-client override)
    • shared queryOptions
    • shared mutationOptions

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 invalidations map, 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:

  1. The fully inferred current router
  2. Mutation variables
  3. Mutation result data
  4. The value returned by onMutate
  5. 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:

  1. API defaults
  2. Endpoint options
  3. 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: string
  • endpoints: 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(): QueryKey for base namespace
  • query and infinite-query endpoints with getKey(state?), getPrefixKey(), and their generated option/hook helpers
  • mutation endpoints with their existing getKey() and useMutation() 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 mutationFn
  • invalidateQueries?: 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 state objects serializable and stable
  • Put cache policy (staleTime, gcTime, polling) at endpoint level
  • Prefer the router-level invalidations map 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.