Generate TanStack Query Hooks from OpenAPI: Own the Last Mile
Decoupling Contract Generation from Query Logic: A Hybrid Architecture for TanStack Query
Current Situation Analysis
API-first development mandates that the OpenAPI specification serves as the single source of truth across backend services and frontend clients. However, the transition from a static contract to a reactive, cache-aware frontend layer introduces a persistent engineering friction point: the "last mile" of hook generation.
Teams typically face a binary choice when bridging OpenAPI and TanStack Query. The first path relies on monolithic, community-maintained generators that output complete hook implementations. The second path abandons automation entirely, forcing developers to manually write and maintain typed query wrappers. Both approaches carry hidden technical debt.
Monolithic generators attempt to solve two fundamentally different problems simultaneously: contract parsing and application-specific query ergonomics. OpenAPI parsing requires strict adherence to HTTP semantics, parameter serialization, and schema validation. TanStack Query, conversely, demands flexibility around cache invalidation, pagination strategies, SSR hydration, and mutation side effects. When a single tool tries to own both, it inevitably becomes rigid. Feature requests for custom query key factories, selective invalidation, or framework-specific wrappers accumulate faster than maintainers can address them. Teams either fork the generator, maintain patch branches, or strip out type safety to regain control.
Manual implementation avoids rigidity but sacrifices correctness. Hand-writing hooks means duplicating parameter serialization logic, manually aligning TypeScript types with evolving specs, and consistently implementing error boundaries. The cognitive load scales with API surface area, not application complexity.
The overlooked reality is that contract translation and query orchestration should be decoupled. OpenAPI parsing is a deterministic, correctness-sensitive problem best delegated to specialized tooling. Query layer construction is an application-specific problem that requires direct ownership. Treating them as a single pipeline creates unnecessary coupling and slows frontend iteration.
WOW Moment: Key Findings
Splitting the pipeline into a contract parser and a local hook generator fundamentally changes maintenance dynamics. The table below contrasts the monolithic generator approach with a split architecture across three critical engineering metrics.
| Approach | Type Coverage | Customization Latency | Maintenance Overhead |
|---|---|---|---|
| Monolithic Generator | High (compile-time) | High (upstream PR cycle) | Linear with app complexity |
| Split Architecture | High (compile-time) | Low (local iteration) | Constant (contract layer) + Linear (hook layer) |
Why this matters: Customization latency measures how quickly a team can adapt query behavior to new product requirements. In monolithic setups, adding a custom invalidation strategy or SSR hydration pattern requires modifying upstream code, waiting for releases, or maintaining forks. In a split architecture, the contract layer remains stable while the hook layer evolves independently. Maintenance overhead shifts from tracking upstream breaking changes to managing a lightweight, repository-owned script. This enables teams to implement application-specific patterns—optimistic updates, suspense boundaries, cache normalization—without compromising type safety or contract alignment.
Core Solution
The architecture delegates OpenAPI parsing to @apical-ts/craft and retains full ownership of the TanStack Query layer through a repository-local generator. This separation aligns with the single responsibility principle: contract tooling handles serialization, validation, and type inference; the local generator handles cache orchestration and React Query ergonomics.
Step 1: Generate Contract Artifacts
Run @apical-ts/craft to parse the OpenAPI specification and emit three artifact categories:
- Shared validation schemas (Zod/TypeBox)
- Route metadata (HTTP methods, paths, parameter shapes)
- Typed transport functions (serialized requests, response unions)
npx @apical-ts/craft generate \
--input ./specs/platform-api.yaml \
--output ./src/contracts/generated \
--format typescript \
--include-schemas \
--include-client
This command produces a predictable directory structure:
src/contracts/generated/
├── schemas/
├── routes/
└── client/
The client layer exports pure functions that handle HTTP transport, parameter serialization, and response parsing. These functions are framework-agnostic and can be reused across React, React Native, or server-side contexts.
Step 2: Build the Local Hook Generator
Create a TypeScript script that reads the generated route metadata and client functions, then emits TanStack Query wrappers. The generator should remain thin, focusing solely on query key construction, hook signatures, and default configuration.
// scripts/generate-query-hooks.ts
import { readFileSync, writeFileSync, mkdirSync } from "fs";
import { join } from "path";
const CONTRACT_DIR = "./src/contracts/generated";
const OUTPUT_DIR = "./src/queries/generated";
interface RouteMeta {
operationId: string;
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
path: string;
hasParams: boolean;
hasBody: boolean;
}
function loadRouteMetadata(): RouteMeta[] {
const raw = readFileSync(join(CONTRACT_DIR, "routes/index.json"), "utf-8");
return JSON.parse(raw);
}
function generateHookTemplate(meta: RouteMeta): string {
const isQuery = meta.method === "GET";
const hookName = `use${meta.operationId}`;
const clientImport = `import { ${meta.operationId} } from "../../contracts/generated/client";`;
if (isQuery) {
return `
${clientImport}
import { useQuery } from "@tanstack/react-query";
export function ${hookName}(
args: Parameters<typeof ${meta.operationId}>[0],
options?: Omit<Parameters<typeof useQuery>[0], "queryKey" | "queryFn">
) {
type Result = Awaited<ReturnType<typeof ${meta.operationId}>>;
return useQuery<Result, Error>({
queryKey: ["${meta.operationId}", args],
queryFn: () => ${meta.operationId}(args),
...options,
});
}`;
}
return `
${clientImport}
import { useMutation } from "@tanstack/react-query";
export function ${hookName}(
options?: Parameters<typeof useMutation>[0]
) {
type Variables = Parameters<typeof ${meta.operationId}>[0];
type Result = Awaited<ReturnType<typeof ${meta.operationId}>>;
return useMutation<Result, Error, Variables>({
mutationFn: (vars) => ${meta.operationId}(vars),
...options,
});
}`;
}
function emitHooks(): void {
mkdirSync(OUTPUT_DIR, { recursive: true });
const routes = loadRouteMetadata();
routes.forEach((route) => {
const code = generateHookTemplate(route);
const fileName = `${route.operationId}.ts`;
writeFileSync(join(OUTPUT_DIR, fileName), code.trim(), "utf-8");
});
console.log(`Generated ${routes.length} query hooks.`);
}
emitHooks();
Step 3: Integrate into Application Workflow
The generated hooks expose clean, fully typed interfaces. Application code consumes them without touching transport logic or schema validation.
// components/InventoryPanel.tsx
import { useFetchInventoryItems } from "@/queries/generated/useFetchInventoryItems";
import { useCreateShipment } from "@/queries/generated/useCreateShipment";
export function InventoryPanel() {
const { data, isPending, error } = useFetchInventoryItems(
{ warehouseId: "WH-04", status: ["active"] },
{ staleTime: 1000 * 60 * 5 }
);
const createShipment = useCreateShipment({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["useFetchInventoryItems"] });
},
});
if (isPending) return <Skeleton />;
if (error) return <ErrorBoundary error={error} />;
return (
<div>
<ShipmentList items={data.items} />
<button onClick={() => createShipment.mutate({ items: data.items })}>
Dispatch
</button>
</div>
);
}
Architecture Rationale
- Delegation of Deterministic Work: OpenAPI parsing involves strict rules around parameter serialization, response unions, and schema validation.
@apical-ts/crafthandles these correctly and consistently. Reimplementing this logic in a hook generator introduces unnecessary risk. - Ownership of Application Patterns: TanStack Query is intentionally unopinionated. Query key strategies, invalidation scopes, pagination wrappers, and SSR hydration conventions vary by product. Keeping the hook layer local allows teams to iterate on these patterns without upstream dependencies.
- Framework Agnosticism: The contract layer produces pure functions. The same generated client can power React Query hooks, React Server Components, or CLI tools. This prevents frontend framework lock-in at the transport layer.
- AI-Ready Surface: A thin, repository-owned generator is highly compatible with AI coding agents. Teams can prompt agents to modify hook templates, add custom options, or implement framework-specific wrappers without navigating complex upstream codebases.
Pitfall Guide
1. Over-Generating Query Configuration
Explanation: Attempting to bake every React Query option (staleTime, retry, suspense, placeholderData) into the generator template creates rigid output that rarely matches app requirements.
Fix: Keep generated hooks minimal. Expose an options parameter that merges with defaults. Implement application-specific configurations in wrapper factories or React context providers.
2. Flat Query Key Structures
Explanation: Using simple arrays like ["endpoint", params] prevents granular cache invalidation. When one parameter changes, unrelated queries may be invalidated unnecessarily.
Fix: Implement hierarchical query key factories. Structure keys as ["resource", "subcategory", identifier, params] to enable partial invalidation and optimistic updates without cache thrashing.
3. Coupling Hooks to UI State Management
Explanation: Generating hooks that directly manage loading spinners, error toasts, or navigation redirects mixes data fetching with presentation logic. This violates separation of concerns and makes hooks difficult to test.
Fix: Let TanStack Query manage isPending, isError, and data. Handle UI side effects in components or custom hooks that compose query results with state management libraries.
4. Skipping Runtime Schema Validation
Explanation: Assuming OpenAPI types guarantee runtime safety ignores network mutations, backend drift, and malformed responses. Compile-time types do not catch runtime payload mismatches.
Fix: Integrate Zod or TypeBox validation in the transport layer generated by @apical-ts/craft. Validate responses before they reach the query cache to prevent type corruption and silent failures.
5. Neglecting SSR Hydration Patterns
Explanation: Generating hooks without considering server-side rendering causes hydration mismatches. Queries that execute during SSR may not align with client-side cache state.
Fix: Pre-fetch data in server components or loaders, serialize the query cache, and hydrate it on the client. Modify the generator to output hooks that respect dehydratedState when available.
6. Hardcoding HTTP Transport
Explanation: Tying generated hooks to a specific HTTP client (fetch, axios, ky) limits reusability and complicates testing. It also prevents environment-specific transport configurations (mock servers, proxy layers).
Fix: Abstract the transport layer via dependency injection or context. Generate hooks that accept a transport instance, allowing runtime swapping for testing, SSR, or multi-tenant environments.
7. Forgetting Mutation Side Effects
Explanation: Generating mutations without onSuccess, onSettled, or onError callbacks forces developers to manually wire invalidation and cache updates in every component.
Fix: Include parameterized side-effect hooks in the generator template. Allow route metadata to specify default invalidation targets, while keeping them overridable at the call site.
Production Bundle
Action Checklist
- Audit existing OpenAPI workflows: Identify which contract parsing logic is duplicated across frontend and backend teams.
- Install and configure
@apical-ts/craft: Set up CI/CD steps to regenerate contract artifacts on spec changes. - Scaffold the local hook generator: Create a TypeScript script that reads route metadata and emits TanStack Query wrappers.
- Implement query key factories: Replace flat arrays with hierarchical structures that support partial invalidation.
- Add runtime validation: Integrate Zod schemas into the transport layer to catch payload mismatches before caching.
- Configure SSR hydration: Pre-fetch on server, serialize cache, and hydrate client-side to prevent mismatches.
- Establish mutation side-effect conventions: Define default invalidation patterns and expose them as overridable options.
- Add generator to build pipeline: Run hook generation as a pre-commit or CI step to ensure alignment with the latest spec.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Small team, rapidly evolving API | Split Architecture | Fast iteration on query patterns without upstream delays | Low initial setup, minimal long-term maintenance |
| Enterprise with strict governance | Monolithic Generator + Fork | Centralized control, standardized patterns across teams | High maintenance overhead, slower feature delivery |
| Multi-framework frontend (React, Vue, Svelte) | Split Architecture | Contract layer remains framework-agnostic; hook generators stay isolated | Moderate setup cost, high reusability |
| Legacy API with inconsistent specs | Manual Hooks + Type Guards | Generators struggle with malformed or undocumented endpoints | High initial effort, reduces runtime errors |
| High-frequency real-time updates | Split Architecture + WebSocket Bridge | Query layer handles REST; custom transport handles live streams | Low incremental cost, prevents cache corruption |
Configuration Template
Copy this configuration into your repository to automate contract parsing and hook generation.
// package.json scripts
{
"scripts": {
"generate:contracts": "npx @apical-ts/craft generate --input ./specs/api.yaml --output ./src/contracts/generated --format typescript --include-schemas --include-client",
"generate:hooks": "tsx ./scripts/generate-query-hooks.ts",
"generate:all": "npm run generate:contracts && npm run generate:hooks",
"prebuild": "npm run generate:all"
}
}
// scripts/generate-query-hooks.ts (production-ready variant)
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
import { join, basename } from "path";
const CONTRACT_DIR = "./src/contracts/generated";
const OUTPUT_DIR = "./src/queries/generated";
function ensureDir(path: string) {
mkdirSync(path, { recursive: true });
}
function loadRoutes(): Array<{ operationId: string; method: string }> {
const raw = readFileSync(join(CONTRACT_DIR, "routes/index.json"), "utf-8");
return JSON.parse(raw);
}
function buildHookCode(route: { operationId: string; method: string }): string {
const isQuery = route.method === "GET";
const baseImport = `import { ${route.operationId} } from "../../contracts/generated/client";`;
const queryImport = `import { useQuery } from "@tanstack/react-query";`;
const mutationImport = `import { useMutation } from "@tanstack/react-query";`;
if (isQuery) {
return `
${baseImport}
${queryImport}
export function use${route.operationId}(
args: Parameters<typeof ${route.operationId}>[0],
options?: Omit<Parameters<typeof useQuery>[0], "queryKey" | "queryFn">
) {
type Result = Awaited<ReturnType<typeof ${route.operationId}>>;
return useQuery<Result, Error>({
queryKey: ["${route.operationId}", args],
queryFn: () => ${route.operationId}(args),
...options,
});
}`;
}
return `
${baseImport}
${mutationImport}
export function use${route.operationId}Mutation(
options?: Parameters<typeof useMutation>[0]
) {
type Variables = Parameters<typeof ${route.operationId}>[0];
type Result = Awaited<ReturnType<typeof ${route.operationId}>>;
return useMutation<Result, Error, Variables>({
mutationFn: (vars) => ${route.operationId}(vars),
...options,
});
}`;
}
function run() {
ensureDir(OUTPUT_DIR);
const routes = loadRoutes();
routes.forEach((route) => {
const code = buildHookCode(route);
const fileName = `${route.operationId}.ts`;
writeFileSync(join(OUTPUT_DIR, fileName), code.trim(), "utf-8");
});
console.log(`✅ Generated ${routes.length} query hooks in ${OUTPUT_DIR}`);
}
run();
Quick Start Guide
- Initialize contract generation: Run
npx @apical-ts/craft generate --input ./specs/api.yaml --output ./src/contracts/generated --format typescript --include-schemas --include-clientto produce typed client functions and route metadata. - Deploy the hook generator: Place the
generate-query-hooks.tsscript in yourscripts/directory and execute it withtsx ./scripts/generate-query-hooks.ts. - Verify output: Check
src/queries/generated/for TypeScript files containinguseQueryanduseMutationwrappers aligned with your OpenAPI operations. - Integrate into components: Import generated hooks, pass parameters, and configure
optionsfor staleTime, retry, or invalidation callbacks. - Automate in CI: Add
npm run generate:allto your prebuild or CI pipeline to ensure hooks stay synchronized with spec changes.
