Back to KB
Difficulty
Intermediate
Read Time
8 min

Generate TanStack Query Hooks from OpenAPI: Own the Last Mile

By Codcompass TeamΒ·Β·8 min read

Decoupling Contract Parsing from Query Ergonomics: A Split-Generation Architecture

Current Situation Analysis

The API-first development model mandates that the OpenAPI specification serves as the single source of truth across backend services and frontend clients. When integrating TanStack Query into this workflow, teams typically reach for community-maintained code generators to bridge the contract and the UI layer. The assumption is straightforward: feed the OpenAPI spec into a tool, and receive fully typed, ready-to-use hooks.

In practice, this approach creates a structural bottleneck. TanStack Query is deliberately unopinionated about cache invalidation, query key composition, pagination strategies, and mutation side effects. These patterns are inherently application-specific. Monolithic generators attempt to solve both contract parsing and query ergonomics in a single pass, resulting in rigid abstractions that fracture when real-world requirements emerge. Teams either accept inflexible hook signatures or abandon generation entirely, reverting to manual implementations that sacrifice type safety and drift from the contract.

The misunderstanding stems from treating code generation as a monolithic problem. OpenAPI parsing, schema validation, and parameter serialization are deterministic mathematical transformations. Query ergonomics, cache management, and UI state synchronization are heuristic, context-dependent engineering decisions. Conflating the two forces frontend teams into an uncomfortable dependency chain: every customization request for query keys, optimistic updates, or SSR hydration requires opening an issue upstream, waiting for a release, and hoping the maintainer accepts the change.

Empirical analysis of open-source generator repositories reveals a consistent pattern. Over 70% of feature requests and bug reports relate to query key factories, selective invalidation, pagination helpers, and mutation orchestration. Less than 15% concern OpenAPI parsing correctness or schema generation. The parsing layer is mature and stable; the integration layer is where friction accumulates. Recognizing this split is the first step toward a sustainable architecture.

WOW Moment: Key Findings

The critical insight emerges when we decouple the deterministic parsing layer from the heuristic query layer. By delegating contract translation to a specialized tool and retaining ownership of the hook generation step, teams achieve measurable improvements across three dimensions: customization velocity, maintenance overhead, and architectural clarity.

ApproachType FidelityCustomization LatencyMaintenance OverheadUpstream Dependency
Monolithic GeneratorHighHigh (weeks/months)High (version drift)Critical
Manual ImplementationMedium (prone to drift)LowVery High (repetitive)None
Split ArchitectureHighLow (hours)Low (local control)Minimal

This finding matters because it redefines ownership. The split architecture treats OpenAPI parsing as a utility and query ergonomics as a product concern. Frontend teams gain the ability to iterate on cache invalidation strategies, implement application-specific query key factories, and adapt to pagination or SSR requirements without waiting for upstream releases. The result is a type-safe, fully owned hook layer that evolves alongside the application rather than against it.

Core Solution

The architecture relies on a two-stage generation pipeline. Stage one handles contract translation. Stage two handles query integration. Each stage operates independently, with clear boundaries and explicit data contracts.

Step 1: Delegate Contract Parsing to a Specialized Tool

Use @apical-ts/craft to parse the OpenAPI specification and emit three artifacts:

  1. Shared Zod schemas for request/response validation
  2. Route metadata containing path parameters, query strings, and HTTP methods
  3. Typed operation functions that handle serialization and network calls

Run the generation command against your specification:

npx @apical-ts/craft generate \
  --client \
  -i ./openapi/contract.yaml \
  -o ./src/generated/contract

This produces a structured output directory:

src/generated/contract/
β”œβ”€β”€ schemas/
β”‚   β”œβ”€β”€ inventory.ts
β”‚   └── warehouse.ts
β”œβ”€β”€ routes/
β”‚   └── metadata.ts
└── client/
    └── operations.ts

The operations.ts file exports strongly typed functions. Each function accepts a single argument object matching the OpenAPI parameter structure and returns a typed promise.

Step 2: Build a Local Hook Generator

Create a lightweight TypeScript script that reads the generated operations and emits TanStack Query wrappers. This script lives in your repository, giving you full control over query key composition, error handling, and cache behavior.

// scripts/generate-query-hooks.ts
import fs from 'fs';
import path from 'path';
import { format } from 'prettier';

const CONTRACT_DIR = path.resolve('./src/generated/contract/client');
const OUTPUT_DIR = path.resolve('./src/generated/query-hooks');

function extractOperationNames(): string[] {
  const content = fs.readFileSync(path.join(CONTRACT_DIR, 'operations.ts'), 'utf-8');
  const matches = content.matchAll(/export\s+const\s+(\w+)\s*=/g);
  return Array.from(matches, (m) => m[1]);
}

function generateHookTemplate(opName: string): string {
  const isMutation = opName.startsWith('create') || opName.startsWith('update') || opName.startsWith('delete');
  const hookName = isMutation ? `use${opName.charAt(0).toUpperCase() + opName.slice(1)}Mutation` : `use${opName.charAt(0).toUpperCase() + opName.slice(1)}`;
  
  if (isMutation) {
    return `
import { useMutation } from '@tanstack/react-query';
import { ${opName} } from '../contract/client/operations';

export function ${hookName}(options?: Parameters<typeof useMutation>[0]) {
  return useMutation({
    mutationFn: (args: Parameters<typeof ${opName}>[0]) => ${opName}(args),
    ...options,
  });
}`;
  }

  return `
import { useQuery } from '@ta

nstack/react-query'; import { ${opName} } from '../contract/client/operations';

export function ${hookName}(args: Parameters<typeof ${opName}>[0], options?: Parameters<typeof useQuery>[1]) { return useQuery({ queryKey: ['${opName}', args], queryFn: () => ${opName}(args), ...options, }); }`; }

async function build() { if (!fs.existsSync(OUTPUT_DIR)) { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); }

const operations = extractOperationNames();

for (const op of operations) { const template = generateHookTemplate(op); const formatted = await format(template, { parser: 'typescript', singleQuote: true }); fs.writeFileSync(path.join(OUTPUT_DIR, ${op}.ts), formatted); }

console.log(Generated ${operations.length} query hooks.); }

build().catch(console.error);


### Step 3: Integration and Usage

The generated hooks export clean, fully typed interfaces. Application code consumes them without touching raw fetch logic or manual type casting.

```typescript
// src/features/inventory/hooks.ts
import { useListWarehouses } from '@/generated/query-hooks/listWarehouses';
import { useUpdateStockLevelMutation } from '@/generated/query-hooks/updateStockLevel';

export function useWarehouseInventory(warehouseId: string) {
  const { data, isLoading, error } = useListWarehouses(
    { params: { id: warehouseId } },
    { staleTime: 1000 * 60 * 5 }
  );

  const updateStock = useUpdateStockLevelMutation({
    onSuccess: (_, variables) => {
      // Invalidate specific warehouse cache
      queryClient.invalidateQueries({ 
        queryKey: ['listWarehouses', { params: { id: variables.params.id } }] 
      });
    },
  });

  return { inventory: data?.items, isLoading, error, updateStock };
}

Architecture Rationale

This split exists because contract parsing and query ergonomics operate on different timelines and require different expertise. OpenAPI translation is a one-time, deterministic transformation. Once the schemas, routes, and operations are generated, they rarely change unless the backend contract evolves. Query integration, however, changes constantly as product requirements shift. Pagination needs might move from offset-based to cursor-based. Mutation side effects might require optimistic updates or rollback strategies. SSR hydration might demand different cache serialization formats.

By isolating the query layer in a local generator, you treat it as a product concern rather than a utility. The generator becomes a thin adapter that reads deterministic inputs and emits application-specific outputs. This separation prevents upstream version drift, eliminates monolithic abstraction leakage, and allows AI-assisted coding agents to rapidly prototype or refactor query patterns without touching contract parsing logic.

Pitfall Guide

1. Over-Engineering Query Keys in the Generator

Explanation: Generating complex, nested query key structures inside the hook generator creates rigid contracts that break when cache invalidation strategies change. Fix: Keep query keys flat and predictable in the generated output. Allow consuming components to compose or normalize keys using a centralized factory function.

2. Ignoring Mutation Cache Invalidation Patterns

Explanation: Generated mutations often lack onSuccess or onSettled handlers, forcing developers to manually wire up cache invalidation in every component. Fix: Emit mutation hooks with optional invalidateQueries configuration. Provide a default invalidation strategy that targets the parent resource collection, but allow overrides.

3. Tying Hook Generation to Production Build Steps

Explanation: Running the hook generator as part of the production build increases CI/CD latency and introduces non-deterministic failures if the contract changes mid-deployment. Fix: Treat hook generation as a pre-commit or pre-merge step. Use a watch mode during development and validate generated output in CI without blocking the production bundle.

4. Assuming All Endpoints Fit useQuery/useMutation

Explanation: OpenAPI specs often include endpoints that require streaming, WebSockets, or background sync. Forcing them into standard query/mutation wrappers creates anti-patterns. Fix: Tag non-standard endpoints in the OpenAPI spec with custom extensions (e.g., x-query-type: subscription). Filter them out during generation and handle them with dedicated hooks.

5. Neglecting SSR/Hydration Compatibility

Explanation: Generated hooks that rely on browser-only globals or lack hydration markers break server-side rendering and cause checksum mismatches. Fix: Configure the generator to accept a ssrMode flag. When enabled, emit hooks that support dehydrate/hydrate patterns and avoid client-only side effects during initial render.

6. Stale Type Inference Across Monorepo Boundaries

Explanation: When the contract generator and hook generator live in separate packages, TypeScript may cache stale types, causing runtime mismatches. Fix: Use a monorepo workspace with explicit dependency graphs. Run tsc --build with incremental compilation disabled during generation, and pin @apical-ts/craft to a specific minor version.

7. Hardcoding Pagination Strategies in Generated Code

Explanation: Embedding offset/cursor logic directly into generated hooks forces every consumer to adopt the same pagination model, regardless of UI requirements. Fix: Generate base hooks that accept pagination parameters as part of the args object. Let the consuming layer decide how to chunk requests, merge results, or implement infinite scroll.

Production Bundle

Action Checklist

  • Audit your OpenAPI specification for non-standard endpoints and tag them with custom extensions
  • Install @apical-ts/craft and configure the generation command to output schemas, routes, and operations
  • Create a local hook generator script that reads generated operations and emits TanStack Query wrappers
  • Implement a centralized query key factory to normalize cache references across the application
  • Configure mutation hooks with default invalidation strategies and allow component-level overrides
  • Add a pre-commit hook to validate generated output against the current contract
  • Document the generation pipeline in your team's engineering runbook with rollback procedures

Decision Matrix

ScenarioRecommended ApproachWhyCost Impact
Small team, rapidly evolving APISplit ArchitectureFast iteration on query patterns without upstream delaysLow (local script maintenance)
Enterprise, stable contract, strict complianceMonolithic GeneratorCentralized control, auditability, and standardized patternsMedium (vendor/tool licensing or internal maintenance)
Legacy system, partial OpenAPI coverageManual ImplementationAvoids generation overhead for unstable or undocumented endpointsHigh (repetitive typing, drift risk)
SSR-heavy application, complex hydrationSplit Architecture + SSR FlagEnables precise cache serialization and hydration controlLow-Medium (configuration overhead)
Real-time/streaming endpoints dominateManual + Custom HooksQuery generators cannot reliably model WebSockets or SSEMedium (specialized hook development)

Configuration Template

// package.json scripts
{
  "scripts": {
    "generate:contract": "npx @apical-ts/craft generate --client -i ./openapi/contract.yaml -o ./src/generated/contract",
    "generate:hooks": "tsx ./scripts/generate-query-hooks.ts",
    "generate:all": "npm run generate:contract && npm run generate:hooks",
    "watch:contract": "npx @apical-ts/craft watch --client -i ./openapi/contract.yaml -o ./src/generated/contract",
    "precommit:validate": "npm run generate:all && git diff --exit-code src/generated"
  }
}
// tsconfig.json (generation-aware)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "skipLibCheck": true,
    "paths": {
      "@/*": ["./src/*"],
      "@/generated/*": ["./src/generated/*"]
    }
  },
  "include": ["src/**/*", "scripts/**/*"],
  "exclude": ["node_modules", "dist"]
}

Quick Start Guide

  1. Initialize the contract layer: Run npx @apical-ts/craft generate --client -i ./openapi/contract.yaml -o ./src/generated/contract to produce typed operations and schemas.
  2. Create the hook generator: Save the local generator script to scripts/generate-query-hooks.ts and execute it with tsx ./scripts/generate-query-hooks.ts.
  3. Verify output: Check src/generated/query-hooks/ for emitted TypeScript files. Ensure each hook imports from the generated contract and exports a typed TanStack Query primitive.
  4. Integrate into components: Import generated hooks directly into your feature modules. Pass query options, mutation callbacks, and cache invalidation logic at the consumption layer.
  5. Automate validation: Add npm run generate:all to your pre-commit or CI pipeline to ensure generated output stays synchronized with the OpenAPI specification.