# routar — Full API Reference > Schema-first HTTP API client with end-to-end type safety and runtime validation. Define your API once — reuse it across any transport, environment, or HTTP client. Built for frontend teams that manage their own API schema. ## Installation ```bash # with fetch (built into core — zero extra dependencies) npm install @routar/core # with axios npm install @routar/core @routar/axios axios # with ky npm install @routar/core @routar/ky ky # for testing with MSW npm install @routar/core msw npm install --save-dev @routar/msw # with TanStack Query (React) npm install @routar/core @routar/react-query @tanstack/react-query ``` --- ## Core Pattern ```ts import { z } from 'zod'; import { endpoint, defineRouter, createApi, createFetchExecutor } from '@routar/core'; const TodoSchema = z.object({ id: z.number(), title: z.string(), completed: z.boolean() }); const todoRouter = defineRouter('/todos', { getList: endpoint({ method: 'GET', path: '/', response: z.array(TodoSchema) }), getDetail: endpoint({ method: 'GET', path: '/:id', response: TodoSchema, request: z.object({ path: z.object({ id: z.number() }) }) }), create: endpoint({ method: 'POST', path: '/', response: TodoSchema, request: z.object({ body: z.object({ title: z.string() }) }) }), update: endpoint({ method: 'PATCH', path: '/:id', response: TodoSchema, request: z.object({ path: z.object({ id: z.number() }), body: z.object({ completed: z.boolean() }), }) }), remove: endpoint({ method: 'DELETE', path: '/:id', request: z.object({ path: z.object({ id: z.number() }) }), response: z.object({}) }), }); const executor = createFetchExecutor('https://api.example.com'); const todoApi = createApi(executor, todoRouter); // Fully-typed calls — no type annotations needed const todos = await todoApi.getList({}); // Todo[] const todo = await todoApi.getDetail({ path: { id: 1 } }); // Todo const next = await todoApi.create({ body: { title: 'buy milk' } }); // Todo await todoApi.update({ path: { id: 1 }, body: { completed: true } }); await todoApi.remove({ path: { id: 1 } }); ``` --- ## endpoint(spec) Defines a single endpoint. Use instead of a plain object to get full type inference on `adapter`. **Signature:** ```ts endpoint(spec: { method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; path: string; request?: Validator; // optional — for endpoints with no params response: Validator; adapter?: (raw: ValidatorOutput) => unknown; }): EndpointSpec ``` **Path param enforcement** — if `path` contains `:param` segments, `request.path` must include matching keys. This is a compile-time error: ```ts // ✅ correct endpoint({ method: 'GET', path: '/:id', request: z.object({ path: z.object({ id: z.number() }) }), response: TodoSchema, }); // ❌ compile error — ':id' declared but request.path.id missing endpoint({ method: 'GET', path: '/:id', request: z.object({ query: z.object({ q: z.string() }) }), response: TodoSchema, }); ``` **With adapter** — raw is inferred from the response schema, no cast needed: ```ts const TodoRawSchema = z.object({ id: z.number(), title: z.string(), is_done: z.boolean() }); endpoint({ method: 'GET', path: '/:id', request: z.object({ path: z.object({ id: z.number() }) }), response: TodoRawSchema, adapter: (raw) => ({ // raw: { id: number; title: string; is_done: boolean } id: raw.id, title: raw.title, completed: raw.is_done, // rename field }), }); ``` **Request shape** — `request` schema validates `{ path?, query?, body? }`: ```ts // query params endpoint({ method: 'GET', path: '/search', request: z.object({ query: z.object({ q: z.string(), limit: z.number().optional() }) }), response: z.array(TodoSchema), }); // path + body endpoint({ method: 'PUT', path: '/:id', request: z.object({ path: z.object({ id: z.number() }), body: z.object({ title: z.string(), completed: z.boolean() }), }), response: TodoSchema, }); ``` --- ## defineRouter(prefix, endpoints) Groups endpoints under a shared URL prefix. Supports arbitrary nesting. ```ts import { defineRouter } from '@routar/core'; // Flat router const todoRouter = defineRouter('/todos', { getList: endpoint({ method: 'GET', path: '/', response: z.array(TodoSchema) }), getDetail: endpoint({ method: 'GET', path: '/:id', response: TodoSchema, request: z.object({ path: z.object({ id: z.number() }) }) }), }); // Nested routers — mirror your URL structure const apiRouter = defineRouter('/api', { todos: todoRouter, // → /api/todos, /api/todos/:id users: defineRouter('/users', { getList: endpoint({ method: 'GET', path: '/', response: z.array(UserSchema) }), getDetail: endpoint({ method: 'GET', path: '/:id', response: UserSchema, request: z.object({ path: z.object({ id: z.number() }) }) }), }), }); ``` --- ## createApi(executor, router) Builds a fully-typed API client. Each endpoint becomes an async function: `(params, signal?) => Promise`. **Three call signatures:** ```ts // preferred — pass the result of defineRouter const api = createApi(executor, todoRouter); // inline router with prefix const api = createApi(executor, '/todos', { getList: endpoint({ ... }) }); // no prefix — flat endpoint map const api = createApi(executor, { getList: endpoint({ ... }) }); ``` **Usage:** ```ts // basic calls const todos = await todoApi.getList({}); const todo = await todoApi.getDetail({ path: { id: 1 } }); const next = await todoApi.create({ body: { title: 'buy milk' } }); // nested router access via dot notation const api = createApi(executor, apiRouter); await api.todos.getList({}); await api.users.getDetail({ path: { id: 1 } }); await api.users.todos.getList({}); // deeply nested // cancel in-flight requests with AbortSignal const controller = new AbortController(); const todos = await todoApi.getList({}, controller.signal); controller.abort(); // skip response validation in production const prodApi = createApi(executor, todoRouter, { validate: { request: true, response: process.env.NODE_ENV !== 'production' }, }); // extract types from the client — no duplication import type { ApiTypes } from '@routar/core'; type TodoApiTypes = ApiTypes; type Todo = TodoApiTypes['getDetail']['response']; // { id: number; title: string; completed: boolean } type CreateRequest = TodoApiTypes['create']['request']; // { body: { title: string } } ``` --- ## createExecutor(transportFn, options?) Low-level factory used internally by all executor packages. Use to integrate any HTTP client. `options.plugins` is an array of `ExecutorPlugin` applied in declaration order (first is outermost). ```ts import { createExecutor, logger } from '@routar/core'; const executor = createExecutor( async ({ method, url, body, headers, signal }) => { const res = await myHttpClient.request({ method, url, body, headers, signal }); return res.data; }, { plugins: [logger()] }, ); ``` --- ## dispatchExecutor(resolver) Creates an executor that selects the underlying transport at request time. Use to unify SSR and CSR behind a single API client. ```ts import { dispatchExecutor, createFetchExecutor, createAxiosExecutor } from '@routar/core'; import axios from 'axios'; const serverExecutor = createFetchExecutor('https://api.example.com', { defaultHeaders: async () => { const { cookies } = await import('next/headers'); const token = (await cookies()).get('access_token')?.value; return token ? { Authorization: `Bearer ${token}` } : {}; }, }); const clientExecutor = createAxiosExecutor( axios.create({ baseURL: 'https://api.example.com' }), ); // Pick transport based on environment (SSR vs CSR) export const apiExecutor = dispatchExecutor(() => typeof window === 'undefined' ? serverExecutor : clientExecutor, ); // Or route by URL prefix const executor = dispatchExecutor((opts) => opts.url.startsWith('/internal') ? internalExecutor : publicExecutor, ); // One API client works in both SSR and CSR export const todoApi = createApi(apiExecutor, todoRouter); ``` --- ## createFetchExecutor(baseURL, options?) — @routar/core Uses the native `fetch` API. Ideal for SSR with per-request dynamic headers. ```ts import { createFetchExecutor } from '@routar/core'; // Minimal const executor = createFetchExecutor('https://api.example.com'); // With SSR dynamic headers const executor = createFetchExecutor('https://api.example.com', { defaultHeaders: async () => { const token = await getServerToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }, }); // Next.js App Router — forward cookies from the incoming request const executor = createFetchExecutor('https://api.example.com', { defaultHeaders: async () => { const { cookies } = await import('next/headers'); const token = (await cookies()).get('access_token')?.value; return token ? { Authorization: `Bearer ${token}` } : {}; }, retry: 2, timeout: 8_000, }); ``` Non-2xx responses throw `HttpError`: ```ts import { HttpError } from '@routar/core'; try { await todoApi.getDetail({ path: { id: 999 } }); } catch (err) { if (err instanceof HttpError) { console.log(err.status, err.statusText, err.body); // 404, 'Not Found', { message: '...' } } } ``` --- ## createAxiosExecutor(instanceOrFactory, options?) — @routar/axios Accepts an `AxiosInstance` (CSR) or an async factory (SSR). ```ts import axios from 'axios'; import { createAxiosExecutor } from '@routar/axios'; // CSR — shared instance with interceptors const instance = axios.create({ baseURL: 'https://api.example.com' }); instance.interceptors.request.use((config) => { config.headers.Authorization = `Bearer ${getClientToken()}`; return config; }); const executor = createAxiosExecutor(instance); // SSR — factory, fresh instance per request const executor = createAxiosExecutor(async () => { const token = await getServerToken(); return axios.create({ baseURL: 'https://api.example.com', headers: { Authorization: `Bearer ${token}` }, }); }); ``` --- ## createKyExecutor(instanceOrFactory, options?) — @routar/ky Accepts a `KyInstance` or an async factory. ```ts import ky from 'ky'; import { createKyExecutor } from '@routar/ky'; // CSR — shared instance with hooks const instance = ky.create({ prefixUrl: 'https://api.example.com', hooks: { beforeRequest: [(req) => { req.headers.set('Authorization', `Bearer ${getToken()}`); }], }, }); const executor = createKyExecutor(instance); ``` --- ## Plugins Plugins are named objects with optional lifecycle hooks — `onRequest`, `onResponse`, `onError` — applied via `options.plugins` in declaration order (first is outermost). `onError` must always throw. ```ts import { createExecutor, definePlugin, logger } from '@routar/core'; import { HttpError } from '@routar/core'; const executor = createExecutor(transport, { plugins: [authPlugin, logger()], }); ``` ### definePlugin(plugin) ```ts interface ExecutorPlugin { name?: string; onRequest?: (opts: ExecuteOptions) => ExecuteOptions | Promise; onResponse?: (response: unknown, opts: ExecuteOptions) => unknown | Promise; onError?: (error: unknown, opts: ExecuteOptions) => never | Promise; } // Add a correlation ID header to every request const correlationPlugin = definePlugin({ name: 'correlation-id', onRequest: (opts) => ({ ...opts, headers: { ...opts.headers, 'X-Request-Id': crypto.randomUUID() }, }), }); // Bundle auth header injection + 401 refresh into a single plugin const authPlugin = definePlugin({ name: 'auth', onRequest: async (opts) => ({ ...opts, headers: { ...opts.headers, Authorization: `Bearer ${await getToken()}` }, }), onError: async (err) => { if (err instanceof HttpError && err.status === 401) await refreshToken(); throw err; // onError must re-throw }, }); ``` ### logger(options?) ```ts logger() // defaults to console.log logger({ log: (msg, data) => myLogger.debug(msg, data) }) ``` ### retry & timeout `retry` and `timeout` are options on `createFetchExecutor` (not plugins). For axios/ky, configure them on the underlying instance (`axios.create({ timeout })`, `ky.create({ timeout, retry })`). ```ts import { createFetchExecutor, TimeoutError } from '@routar/core'; const executor = createFetchExecutor('https://api.example.com', { retry: 3, timeout: 5_000, // on timeout the rejection is a TimeoutError (has `.ms`) }); // Retry only server errors (5xx), not client errors (4xx) createFetchExecutor('https://api.example.com', { retry: { count: 3, shouldRetry: (err) => !(err instanceof HttpError && err.status < 500) }, }); ``` --- ## createMswHandlers(router, baseURL, resolvers) — @routar/msw Generates MSW v2 `HttpHandler[]` from a `RouterDef` for use in tests. - Only endpoints with a resolver get a handler — the rest pass through naturally. - Resolver context (`params`, `query`, `body`) is typed and validated through the endpoint's `request` schema. - Nested routers are supported — the resolver map mirrors the router shape. - **Path param coercion:** Use `z.coerce.number()` not `z.number()` for numeric IDs (MSW params are always strings). ```ts import { createMswHandlers } from '@routar/msw'; import { HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { todoRouter } from './todo'; // Basic setup const server = setupServer( ...createMswHandlers(todoRouter, 'https://api.example.com', { getList: () => HttpResponse.json([{ id: 1, title: 'Buy milk', completed: false }]), getDetail: ({ params }) => HttpResponse.json({ id: params.id, title: 'Buy milk', completed: false }), create: ({ body }) => HttpResponse.json({ id: 2, title: body.title, completed: false }), }), ); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); ``` **Partial mocking** — only listed endpoints are intercepted: ```ts createMswHandlers(todoRouter, 'https://api.example.com', { getList: () => HttpResponse.json([]), // getDetail, create, update, remove → not registered, requests reach the real server }); ``` **Nested routers:** ```ts const apiRouter = defineRouter('/api', { todos: todoRouter, users: userRouter }); createMswHandlers(apiRouter, 'https://api.example.com', { todos: { getList: () => HttpResponse.json([]), getDetail: ({ params }) => HttpResponse.json({ id: Number(params.id) }), }, users: { getList: () => HttpResponse.json([]), }, }); ``` --- ## createQueries(api, options?) / routarMutationCache — @routar/react-query Derives TanStack Query `queryOptions` / `mutationOptions` factories from a `createApi` client. The router is recovered from the client (`createApi` stamps it on the non-enumerable `$router` property) — never re-passed. No new hook API: use TanStack's own hooks. GET endpoints become query accessors; non-GET endpoints become mutation accessors (decided at the type level). ```ts import { createQueries } from '@routar/react-query'; // todoApi = createApi(executor, todoRouter); createQueries reads its router automatically export const todoQuery = createQueries(todoApi); ``` **Query accessors (GET)** — `(params?, queryOptions?) => queryOptions`: ```ts useSuspenseQuery(todoQuery.getList({ query: { userId: 1 } })); useQuery(todoQuery.getDetail({ path: { id } }, { staleTime: 60_000 })); queryClient.prefetchQuery(todoQuery.getList()); // SSR prefetch useSuspenseQueries({ queries: [todoQuery.getDetail({ path: { id } })] }); ``` **Mutation accessors (non-GET)** — `(options?) => mutationOptions`; variables go to `.mutate()`: ```ts const create = useMutation(todoQuery.create()); create.mutate({ body: { title: 'buy milk' } }); ``` **Key helpers:** `todoQuery..queryKey(params?)`, `todoQuery..mutationKey`, `todoQuery.$key` (domain root). Shape is `[root, endpointName, params?]`; the root is derived from the router prefix — override with `createQueries(api, { key: 'todo' })`. **Per-endpoint defaults:** pass `defaults` to set option defaults keyed by endpoint name — merged before per-call options (per-call wins). Nested routers supported (the map mirrors the router shape). Mutation endpoints support all mutation options including `invalidates`. The second argument may be a factory `(q) => options` to reference sibling key helpers inside defaults without circular-variable issues. ```ts const todoQuery = createQueries(todoApi, { defaults: { getList: { staleTime: 60_000 }, getDetail: { staleTime: 5 * 60_000 }, }, }); ``` **Error typing:** `error` is typed as TanStack's `DefaultError`. To narrow it to `HttpError` globally, augment TanStack's `Register` interface once — no `createQueries` change needed, accessors pick it up automatically: ```ts import type { HttpError } from '@routar/core'; declare module '@tanstack/react-query' { interface Register { defaultError: HttpError } } ``` **Infinite queries (GET-only):** every query accessor has a `.infinite` callable that returns `infiniteQueryOptions` for `useInfiniteQuery` / `useSuspenseInfiniteQuery` / `prefetchInfiniteQuery`. Declare the pagination contract once in `createQueries({ infinite })` — nested routers supported (the map mirrors the router shape). Call sites then only supply base params (page-independent). Signature: `(params?, override?) => infiniteQueryOptions`. ```ts // Declare the contract once in createQueries export const todoQuery = createQueries(todoApi, { infinite: { getList: { initialPageParam: 1, getNextPageParam: (lastPage, allPages) => lastPage.length === 10 ? allPages.length + 1 : undefined, pageParam: (page) => ({ query: { _page: page } }), // routar-specific }, }, }); // Call site — base params only; contract comes from config useSuspenseInfiniteQuery(todoQuery.getList.infinite({ query: { _limit: 10 } })); // SSR prefetch queryClient.prefetchInfiniteQuery(todoQuery.getList.infinite()); ``` The `pageParam` builder is the one routar-specific concept: instead of writing `queryFn`, describe where the page param goes in the request. Its return value (a deep-partial of the endpoint's request schema) is deep-merged into the base params, then the routar client is called. Do not pass `queryFn` alongside `pageParam`. Page param type is `number`; for cursor (string) pagination, cast at the call site. Other native infinite options (`maxPages`, `getPreviousPageParam`, `select`, `staleTime`, etc.) pass through as-is. Pass a partial override as the second arg to `.infinite()` to merge over the configured contract (call wins). You can also supply the full contract at the call site for ad-hoc use, but declaring it in `createQueries` is recommended. If an endpoint has no `infinite` config and the full contract is not supplied, a clear runtime error is thrown. The field written by `pageParam` must exist in the endpoint's request schema — the merged request is validated by routar. The adapter (if any) runs per page. Per-endpoint defaults from `createQueries(api, { defaults })` merge into `.infinite` before per-call options. Data shape: `InfiniteData`. Key: `todoQuery.getList.infinite.queryKey(params?)` → `["todos", "getList", "infinite", params?]`. This is a prefix-child of the standard key `["todos", "getList"]`, so invalidating the standard key or the domain `$key` also covers the infinite variant. For no-param endpoints, call `.infinite()` without arguments. **Invalidation — pure by default.** Mutations invalidate nothing unless you declare target keys with `invalidates` and wire `routarMutationCache(getClient)` once at `QueryClient` creation. Without wiring, `invalidates` does nothing; in development a one-time `console.warn` is logged. Prefer **narrow** invalidation — use `todoQuery.getList.queryKey()` (the specific key affected) rather than `todoQuery.$key` (whole domain). Reserve `$key` only when a mutation truly invalidates every query in the domain; it refetches all active lists and details, which can be costly. ```ts import { QueryClient } from '@tanstack/react-query'; import { routarMutationCache } from '@routar/react-query'; let queryClient: QueryClient; queryClient = new QueryClient({ mutationCache: routarMutationCache(() => queryClient), }); // prefer narrow scope useMutation(todoQuery.create({ invalidates: [todoQuery.getList.queryKey()] })); // whole-domain only when truly needed useMutation(todoQuery.create({ invalidates: [todoQuery.$key] })); ``` Without the wiring, `invalidates` is ignored — use a native `onSuccess` instead. **Optimistic updates:** accessor options are merged, so pass native `onMutate` / `onError` / `onSettled`; the key helpers make `cancelQueries` / `setQueryData` ergonomic. The library only fills `mutationFn` / `mutationKey` (+ `meta.invalidates`), so it never overwrites your handlers. **Adapter + cache shape:** if an endpoint has an `adapter`, the value stored in the query cache is the **adapted** output (e.g. `{ ...todo, label }`), not the raw response. `setQueryData` callbacks in optimistic updates must produce that adapted shape — include any derived fields that the adapter adds. Mutation responses are likewise adapted before being returned. --- ## Type Utilities ### ApiTypes Extracts `{ request, response }` types for every endpoint from an API client. ```ts import type { ApiTypes } from '@routar/core'; type TodoApiTypes = ApiTypes; type Todo = TodoApiTypes['getDetail']['response']; // { id: number; title: string; completed: boolean } type CreateRequest = TodoApiTypes['create']['request']; // { body: { title: string } } type GetListResponse = TodoApiTypes['getList']['response']; // Todo[] ``` ### PathParams Extracts `:param` names from a path string as a union of string literals. ```ts import type { PathParams } from '@routar/core'; type P = PathParams<'/:userId/posts/:postId'>; // 'userId' | 'postId' type Q = PathParams<'/todos'>; // never (no dynamic segments) ``` --- ## SSR/CSR Pattern (Next.js) ```ts // remote/lib/executor.ts import { dispatchExecutor, createFetchExecutor } from '@routar/core'; import { createAxiosExecutor } from '@routar/axios'; import axios from 'axios'; const serverExecutor = createFetchExecutor(process.env.API_URL!, { defaultHeaders: async () => { const { cookies } = await import('next/headers'); const token = (await cookies()).get('access_token')?.value; return token ? { Authorization: `Bearer ${token}` } : {}; }, }); const clientExecutor = createAxiosExecutor( axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL }), ); export const apiExecutor = dispatchExecutor(() => typeof window === 'undefined' ? serverExecutor : clientExecutor, ); // remote/services/todo.ts import { createApi } from '@routar/core'; import { apiExecutor } from '../lib/executor'; export const todoApi = createApi(apiExecutor, todoRouter); // one client, works everywhere ``` --- ## Anti-Patterns ```ts // ❌ Don't put .transform() on response — it breaks composition response: z.array(TodoRawSchema).transform(items => items.map(toTodoItem)) // ✅ Use adapter instead endpoint({ response: z.array(TodoRawSchema), adapter: (raw) => raw.map(toTodoItem), }) // ❌ Don't use z.number() for path params in MSW (params are always strings) request: z.object({ path: z.object({ id: z.number() }) }) // ← in MSW context // ✅ Use z.coerce.number() in MSW resolvers request: z.object({ path: z.object({ id: z.coerce.number() }) }) // ❌ Don't create separate server/client API instances const todoServerApi = createApi(serverExecutor, todoRouter); const todoClientApi = createApi(clientExecutor, todoRouter); // ✅ Use dispatchExecutor for one unified client export const todoApi = createApi(dispatchExecutor(() => typeof window === 'undefined' ? serverExecutor : clientExecutor, ), todoRouter); ``` --- ## Error Handling | Error | Package | Thrown when | |-------|---------|-------------| | `ValidationError` | `@routar/core` | `request.parse()` or `response.parse()` fails | | `TimeoutError` | `@routar/core` | Request exceeds the `createFetchExecutor` `timeout` | | `HttpError` | `@routar/core` | `createFetchExecutor` gets a non-2xx response | | `AxiosError` | axios | Network or HTTP error from `@routar/axios` | ```ts import { TimeoutError, ValidationError, HttpError } from '@routar/core'; try { await todoApi.create({ body: { title: '' } }); } catch (err) { if (err instanceof ValidationError) console.log(err.message); if (err instanceof HttpError) console.log(err.status, err.body); if (err instanceof TimeoutError) console.log(`timed out after ${err.ms}ms`); } ```