micro-rq
v0.3.0
Published
Define REST resources once and generate TanStack Query configs without wrapping TanStack Query.
Maintainers
Readme
micro-rq
Define REST endpoints once and use them with TanStack Query without wrapping TanStack Query.
Docs: https://micro-rq-docs.vercel.app/
TanStack Query is excellent at caching, background refetching, retries, mutations, invalidation, and async server-state orchestration. In REST apps, the repeated work is usually not TanStack Query itself. It is the code around it: building URLs, serializing query params, creating stable query keys, adding auth headers, parsing responses, refreshing tokens, and keeping request functions consistent across screens.
micro-rq is a small resource builder for that surrounding REST layer. You describe each REST endpoint once, then use the generated output directly with TanStack Query.
It gives you:
- stable query keys for invalidation and exact cache targeting
- request functions for direct calls and tests
useQuery-ready configs withqueryKeyandqueryFnuseMutation-ready configs withmutationFn- shared base URLs, headers, auth modes, token refresh, response parsing, and error handling
It is useful when your app already uses TanStack Query and you want a typed, consistent REST layer without creating another hook abstraction.
It does not generate React hooks, replace TanStack Query, implement caching, or hide TanStack Query options. You still call useQuery, useMutation, invalidateQueries, and pass options such as enabled, staleTime, select, and onSuccess yourself.
Install
npm install micro-rq @tanstack/react-query@tanstack/react-query is a peer dependency.
Upgrading from 0.2.x
This upgrade has no breaking changes. Existing bearer-token configurations that
use createTokenProvider continue to work without changes.
If your backend uses HttpOnly cookies, you no longer need to create a token provider only to run the refresh request. Configure refresh directly on the API:
const api = createMicroApi({
name: "main",
baseUrl: "/api",
refresh: {
fn: () => auth.refresh.fn(),
},
});The direct refresh operation can return no data, including a 204 No Content
response. Configure refresh either on the API or in tokenProvider, not both.
Upgrading from 0.1.x
Version 0.2.0 removes refresh.selectAccessToken. Save refreshed tokens in
refresh.onSuccess; micro-rq waits for it before retrying the original request.
refresh: {
fn: ({ refreshToken }) => auth.refresh.fn({ refreshToken }),
- selectAccessToken: (tokens) => tokens.accessToken,
onSuccess: (tokens) => {
localStorage.setItem("accessToken", tokens.accessToken);
localStorage.setItem("refreshToken", tokens.refreshToken);
},
}The provider no longer keeps its own access-token copy. getAccessToken is now
the source of truth, so removing the token from application storage takes effect
on the next request.
Quick Start
Step 1: Create an API client
import { createMicroApi } from "micro-rq";
export const api = createMicroApi({
name: "main",
baseUrl: "/api",
});name is included in query keys. This keeps keys from different APIs separate.
Step 2: Define a resource
type User = {
id: string;
name: string;
email: string;
};
type CreateUserDto = {
name: string;
email: string;
};
export const users = api.resource("users", {
list: api.get<User[], { page: number }>("/users", {
query: (params) => params,
}),
detail: api.get<User, string>((id) => `/users/${id}`),
create: api.post<User, CreateUserDto>("/users"),
});GET endpoints become query endpoints. POST, PUT, PATCH, and DELETE endpoints become mutation endpoints.
Step 3: Use query endpoints
const usersQuery = useQuery({
...users.list.toQuery({ page: 1 }),
staleTime: 60_000,
});
const userQuery = useQuery({
...users.detail.toQuery(userId),
enabled: Boolean(userId),
});toQuery() returns only:
{
queryKey,
queryFn,
}The generated queryFn forwards TanStack Query's AbortSignal to fetch, so
cancelling a query also cancels the underlying HTTP request.
Step 4: Use mutation endpoints
const createUser = useMutation({
...users.create.toMutation(),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: users.list.baseKey(),
});
},
});
createUser.mutate({
name: "John",
email: "[email protected]",
});toMutation() returns only:
{
mutationFn,
}Mutation variables are passed to mutate(), not to toMutation().
Infinite Queries
Use toInfiniteQuery() with TanStack Query's useInfiniteQuery:
const postsQuery = useInfiniteQuery({
...posts.list.toInfiniteQuery({
initialPageParam: 0,
keyVariables: {
tag,
limit: 20,
},
getVariables: ({ pageParam, keyVariables }) => ({
skip: pageParam,
...keyVariables,
}),
}),
getNextPageParam: (lastPage) => {
const nextSkip = lastPage.skip + lastPage.limit;
return nextSkip < lastPage.total ? nextSkip : undefined;
},
});getVariables converts any page-number, offset, or cursor pageParam into the
endpoint's variables. keyVariables holds stable filters and page size so
different infinite lists do not share cached data. The generated key uses:
[apiName, resourceName, endpointName, "infinite", keyVariables | null]The page parameter is not part of the key because TanStack Query stores every
page under one infinite-query entry. getNextPageParam remains a TanStack Query
option because the API response decides where the next page comes from.
toInfiniteQuery() also forwards TanStack Query's cancellation signal to
fetch.
Query Keys
Keys follow this shape:
[apiName, resourceName, endpointName, variables?]users.list.baseKey();
// ["main", "users", "list"]
users.list.key({ page: 1 });
// ["main", "users", "list", { page: 1 }]
users.detail.key("user-1");
// ["main", "users", "detail", "user-1"]Use baseKey() when you want to invalidate every query for one endpoint:
queryClient.invalidateQueries({
queryKey: users.list.baseKey(),
});Use key(input) when you want one exact query key.
Request Mapping
Paths can be static or dynamic:
api.get<User[]>("/users");
api.get<User, string>((id) => `/users/${id}`);Use mappers when request variables do not match the final request directly:
api.get<User[], { page: number; search?: string }>("/users", {
query: (params) => params,
});
api.patch<User, { id: string; body: Partial<User> }>(
({ id }) => `/users/${id}`,
{
body: ({ body }) => body,
},
);Query serialization rules:
undefinedvalues are ignored.nullbecomes"null".- arrays repeat keys, for example
?tags=a&tags=b - objects are JSON-stringified.
- existing query parameters in the endpoint path are preserved.
For non-GET methods, variables are sent as the JSON body by default unless a body mapper is provided. GET requests never send a body.
Response Parsing
Use the optional parse mapper to validate or transform successful responses:
api.get<User>("/users/1", {
parse: (data) => userSchema.parse(data),
});The parser receives unknown and can return a value directly or a promise.
Use any validation library or a custom function; micro-rq does not include a
validation dependency. The parser runs only for successful HTTP responses. If
it throws, onError observes the same error once and the caller receives it.
Auth and Refresh
import { createMicroApi, createTokenProvider } from "micro-rq";
type AuthTokens = {
accessToken: string;
refreshToken: string;
};
const authApi = createMicroApi({
name: "auth",
baseUrl: "/api",
});
const auth = authApi.resource("auth", {
refresh: authApi.post<AuthTokens, { refreshToken?: string | null }>("/refresh", {
authMode: "none",
}),
});
const tokenProvider = createTokenProvider({
getAccessToken: () => localStorage.getItem("accessToken"),
getRefreshToken: () => localStorage.getItem("refreshToken"),
refresh: {
fn: ({ refreshToken }) => auth.refresh.fn({ refreshToken }),
onSuccess: (tokens) => {
localStorage.setItem("accessToken", tokens.accessToken);
localStorage.setItem("refreshToken", tokens.refreshToken);
},
onError: () => {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
},
},
});
export const api = createMicroApi({
name: "main",
baseUrl: "/api",
tokenProvider,
authHeader: (token) => ({
Authorization: `Bearer ${token}`,
}),
});If a request returns 401 and refresh is configured, micro-rq refreshes once and retries the original request once. Parallel 401 responses share the same refresh promise.
onSuccess is awaited before the original request is retried. Save the new
tokens there so getAccessToken returns the new access token for the retry.
The token provider does not keep its own copy, so clearing your application's
token storage also logs out future requests.
Endpoint auth modes:
optional: default. Use a token when one exists.none: skip token lookup, auth header injection, and refresh-on-401.required: require an access token before callingfetch; throwsMicroAuthRequiredErrorif missing.
HttpOnly Cookie Sessions
For same-origin cookie sessions, the browser handles Set-Cookie and sends the
cookie on later requests. No tokenProvider or authHeader is needed:
const api = createMicroApi({
name: "main",
baseUrl: "/api",
});For a cross-origin API, use a custom fetcher with credentials:
const cookieFetcher: typeof fetch = (input, init) =>
fetch(input, {
...init,
credentials: "include",
});When a 401 should call a refresh endpoint that replaces the HttpOnly cookie,
use API-level refresh:
const authApi = createMicroApi({
name: "auth",
baseUrl: "/api",
});
const auth = authApi.resource("auth", {
refresh: authApi.post<void>("/auth/refresh", {
authMode: "none",
}),
});
const api = createMicroApi({
name: "main",
baseUrl: "/api",
refresh: {
fn: () => auth.refresh.fn(),
},
});The refresh endpoint may return 204 No Content. Parallel 401 responses
share one refresh operation, then each original request retries once. Configure
refresh either at the API level for cookie sessions or in tokenProvider for
readable tokens, not both.
Use the default authMode: "optional" for protected cookie endpoints.
authMode: "required" checks for a readable access token and therefore is not
appropriate for HttpOnly cookies. Use authMode: "none" for login and refresh
endpoints. Cookie-authenticated writes also need suitable CSRF protection.
Errors
Failed HTTP responses throw MicroApiError.
import { MicroApiError } from "micro-rq";
try {
await users.detail.fn("user-1")();
} catch (error) {
if (error instanceof MicroApiError) {
console.log(error.status);
console.log(error.statusText);
console.log(error.data);
console.log(error.response);
}
}You can also observe failures at the API-client level:
const api = createMicroApi({
name: "main",
baseUrl: "/api",
onError: (error, context) => {
console.log(context.method, context.url, error);
},
});For each request, onError is called once with the final error, including when
refresh or the retried request fails. The original error is still thrown so
TanStack Query retries, error state, callbacks, and error boundaries keep
working normally.
Next.js SSR Hydration
Use generated query configs with TanStack Query's prefetchQuery, then hydrate for Client Components.
// app/products/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import { products } from "../api/resources/products";
import { ProductsClient } from "./products-client";
export default async function ProductsPage() {
const queryClient = new QueryClient();
const params = { limit: 12, skip: 0 };
await queryClient.prefetchQuery(products.list.toQuery(params));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductsClient params={params} />
</HydrationBoundary>
);
}// app/products/products-client.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
import { products } from "../api/resources/products";
export function ProductsClient({ params }: { params: { limit: number; skip: number } }) {
const productsQuery = useQuery({
...products.list.toQuery(params),
});
// Render productsQuery.data.
}TypeScript
Inputs and outputs are inferred from endpoint definitions.
const q = users.detail.toQuery("user-1");
// q.queryFn returns Promise<User>
const m = users.create.toMutation();
// m.mutationFn accepts CreateUserDto and returns Promise<User>These fail type checking:
users.detail.toQuery(123);
users.create.fn({ wrong: "field" });No-variable endpoints do not require undefined:
const me = api.resource("me", {
get: api.get<User>("/me"),
});
me.get.toQuery();
me.get.key();
me.get.fn();Public API
The root package export is the public API.
Runtime exports:
createMicroApicreateTokenProviderMicroApiErrorMicroAuthRequiredError
Type exports:
MicroApiCreateMicroApiConfigMicroRequestContextTokenProviderTokenProviderConfigRefreshTokenConfigBuiltResourceQueryEndpointMutationEndpointQueryConfigMutationConfigMicroQueryKeyVariablesArgsAuthModeBodyTypeHttpMethodMaybePromisePathBuilderRequestMappers
Examples and Docs
Example app:
cd examples/next
npm install
npm run devDocs app source:
cd docs
npm install
npm run devAI Coding Agents
micro-rq publishes version-matched agent docs at node_modules/micro-rq/dist/docs/.
Run this command in your project root to install the local micro-rq agent skill:
npx micro-rq agents initThat command creates or updates:
.agents/
micro-rq/
SKILL.md
references/
index.md
create-micro-api.md
create-token-provider.md
resources.md
tanstack-query.md
errors.mdPublishing
npm run release:check
npm publishrelease:check runs typecheck, tests, type tests, build, and npm pack --dry-run.
Published files are limited to dist, README.md, CHANGELOG.md, and LICENSE.
