February 23, 2026
Typesafe API Code Generation for React in 2026
The ecosystem has converged. Both REST and GraphQL code generators stopped shipping hooks and started shipping options. Here's the full picture.
Sascha Becker
Author12 min read

Typesafe API Code Generation for React in 2026
Every year I revisit the same question: What's the best way to generate typesafe, ergonomic frontend code from my API definitions? The answer keeps changing. This is the 2026 edition.
The short version: The ecosystem has converged. Both the REST and GraphQL worlds independently arrived at the same conclusion — stop generating framework-specific hooks, start generating framework-agnostic options and typed documents instead.
If you've been using generated useGetPet() or useFilmsQuery() hooks, it's time to understand why that pattern is being phased out and what replaced it.
Why the shift away from generated hooks?
This is the single most important change across both ecosystems, so let's address it up front.
Previously, code generators would produce custom React hooks for every endpoint or query. A REST generator would give you useGetPetById(), a GraphQL generator would give you useFilmsQuery(). Convenient? Absolutely. Sustainable? No.
The problems stacked up:
Combinatorial maintenance burden. Every combination of HTTP client (Axios, Fetch, Ky) × data-fetching library (TanStack Query, SWR, Apollo, urql) × framework (React, Vue, Svelte, Solid, Angular) needed its own plugin. The GraphQL Code Generator team maintained dozens of these, each with its own configuration quirks and inconsistencies.
Framework lock-in in the generated output. Generated hooks couple your API layer to React. If your team also maintains a Vue app, or migrates to Solid, the entire codegen pipeline breaks.
Composability issues. React's rules of hooks mean you can't call a generated hook inside a loop or conditionally. You can't easily use them with useQueries() which expects an array of option objects — not hook calls.
Unnecessary abstraction layer. A generated hook is just a thin wrapper around useQuery({ queryKey, queryFn }). Once TanStack Query v5 shipped the queryOptions() helper, that wrapper became pointless overhead.
The GraphQL Code Generator team discussed this shift extensively in their v3/v5 roadmap and the client preset discussion. The Hey API team built their TanStack Query plugin around this philosophy from day one (Issue #653).
The REST Side: OpenAPI Code Generation
@hey-api/openapi-ts
Hey API is the current frontrunner for OpenAPI-to-TypeScript generation. It's the spiritual successor to openapi-typescript-codegen, fully rewritten with a plugin-based architecture.
What it generates:
- Type-safe SDK functions for every endpoint
queryOptions/mutationOptions/infiniteQueryOptionsfunctions for TanStack Query- Query key functions for cache management
- Optional Zod or Valibot validation schemas
What it does not generate: hooks.
Configuration
openapi-ts.config.tsimport { defineConfig } from '@hey-api/openapi-ts';export default defineConfig({input: 'https://api.example.com/openapi.json',output: 'src/client',plugins: ['@hey-api/typescript','@hey-api/sdk','@tanstack/react-query',],});
Run it:
bashnpx @hey-api/openapi-ts
Generated output
The generator produces option functions — plain functions returning objects:
ts// Generated: src/client/@tanstack/react-query.gen.tsexport const getPetByIdOptions = (options: {path: { petId: number };}) => ({queryKey: getPetByIdQueryKey(options),queryFn: () => getPetById(options),});export const addPetMutation = () => ({mutationFn: (options: { body: { name: string } }) =>addPet(options),});
Usage in components
You spread the generated options into TanStack Query's hooks:
tsximport { useQuery, useMutation } from '@tanstack/react-query';import {getPetByIdOptions,addPetMutation,} from './client/@tanstack/react-query.gen';function PetDetail({ petId }: { petId: number }) {const { data } = useQuery({...getPetByIdOptions({ path: { petId } }),staleTime: 5000, // you can add any extra options});const mutation = useMutation({...addPetMutation(),onSuccess: () => {// invalidate, redirect, whatever you need},});return <div>{data?.name}</div>;}
This pattern has a subtle but powerful advantage: you can use the same options with queryClient.prefetchQuery() for SSR, queryClient.getQueryData() for cache reads, and useQueries() for parallel fetches — all fully typed.
ts// Prefetch on the server (Next.js)await queryClient.prefetchQuery(getPetByIdOptions({ path: { petId } }));// Read from cache — the return type is inferredconst cached = queryClient.getQueryData(getPetByIdQueryKey({ path: { petId } }));
HTTP clients
Hey API supports Fetch (default), Axios, Ky, and framework-specific clients like Next.js and Nuxt. The TanStack Query plugin works with React, Vue, Svelte, Solid, and Angular — all from the same generated output.
Orval
Orval is the other major player. Unlike Hey API, Orval still generates custom hooks by default. A useListPets() hook, a useGetPetById() hook, and so on.
orval.config.tsimport { defineConfig } from 'orval';export default defineConfig({petstore: {input: './petstore.yaml',output: {target: './src/api/endpoints.ts',client: 'react-query',mock: true, // generates MSW handlers with Faker.js},},});
Orval's big differentiator is built-in mock generation. It produces MSW request handlers with realistic fake data out of the box, which is excellent for frontend development against APIs that don't exist yet.
There is an open feature request for queryOptions-style output. If you're starting a new project, I'd recommend Hey API for the options-based approach. If you already use Orval and need mock generation, it's still a solid choice.
Head-to-head comparison
| @hey-api/openapi-ts | Orval | |
|---|---|---|
| Output | Options objects | Custom hooks |
| TanStack Query | React, Vue, Svelte, Solid, Angular | React, Vue, Svelte, Solid |
| Mock generation | Separate plugin | Built-in (MSW + Faker.js) |
| Validation | Zod, Valibot | Zod |
| HTTP clients | Fetch, Axios, Ky, Next.js, Nuxt | Axios, Fetch |
| Maturity | Pre-1.0, fast-moving | v8, stable |
The GraphQL Side
graphql-codegen with the Client Preset
GraphQL Code Generator by The Guild is the established tool. The recommended approach is the client preset, which generates typed document objects — not hooks.
What changed
Previously, you'd install @graphql-codegen/typescript-react-apollo or @graphql-codegen/typescript-react-query and get generated hooks. Those plugins are now deprecated and moved to community repos. The official recommendation is:
- Use the client preset to generate a typed
graphql()function - Write your queries inline using that function
- Pass the typed documents to your client library's own hooks
Configuration
codegen.tsimport type { CodegenConfig } from '@graphql-codegen/cli';const config: CodegenConfig = {schema: 'https://api.example.com/graphql',documents: ['src/**/*.{ts,tsx}'],ignoreNoDocuments: true,generates: {'./src/gql/': {preset: 'client',config: {documentMode: 'string',enumsAsTypes: true,},presetConfig: {fragmentMasking: true,},},},};export default config;
Usage with Apollo Client
Apollo Client natively understands TypedDocumentNode, so the integration is seamless:
tsximport { useQuery } from '@apollo/client';import { graphql } from './gql';const AllFilmsQuery = graphql(`query AllFilms {allFilms {films {titlereleaseDate}}}`);function Films() {// data is fully typed — no generics neededconst { data } = useQuery(AllFilmsQuery);return (<ul>{data?.allFilms?.films?.map((film) => (<li key={film?.title}>{film?.title}</li>))}</ul>);}
Usage with TanStack Query
TanStack Query doesn't have native GraphQL support, so you write a small execute function once:
tsimport type { TypedDocumentString } from './gql/graphql';export async function execute<TResult, TVariables>(query: TypedDocumentString<TResult, TVariables>,variables?: TVariables,): Promise<TResult> {const response = await fetch('/graphql', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ query: query.toString(), variables }),});const { data } = await response.json();return data;}
Then use it in your components:
tsximport { useQuery } from '@tanstack/react-query';import { graphql } from './gql';import { execute } from './graphql-client';const PeopleQuery = graphql(`query AllPeople {allPeople {totalCountpeople {name}}}`);function People() {const { data } = useQuery({queryKey: ['allPeople'],queryFn: () => execute(PeopleQuery),});return <span>{data?.allPeople?.totalCount} people</span>;}
Fragment masking
One of the client preset's best features is fragment masking. It enforces that components can only access the data they explicitly declare via fragments:
tsximport { graphql, FragmentType, useFragment } from './gql';const FilmCardFragment = graphql(`fragment FilmCard on Film {titlereleaseDatedirector}`);function FilmCard(props: {film: FragmentType<typeof FilmCardFragment>;}) {const film = useFragment(FilmCardFragment, props.film);// film.title — typed and accessible// film.id — compile error, not in fragmentreturn <h3>{film.title}</h3>;}
This is a game-changer for large codebases. It prevents components from depending on data they didn't request, which makes refactoring queries safe.
Apollo Client users
Apollo's own documentation recommends against using the client preset with Apollo Client apps, stating it generates additional runtime code that is incompatible with Apollo. If you use Apollo, consider using the legacy typescript + typescript-operations plugins. Check the Apollo docs for their recommended setup.
gql.tada: No codegen at all
gql.tada takes a radically different approach: no code generation step. Instead, it uses TypeScript's type system to infer result and variable types from your GraphQL queries at compile time.
How it works
- Your schema is loaded via a TypeScript plugin
- The plugin generates a
graphql-env.d.tstype file - When you write
graphql('query { ... }'), TypeScript parses the query string at the type level - Result and variable types are inferred — no build step, no watcher
Setup
bashnpm install gql.tada
tsconfig.json{"compilerOptions": {"plugins": [{"name": "gql.tada/ts-plugin","schema": "./schema.graphql","tadaOutputLocation": "./src/graphql-env.d.ts"}]}}
Usage
tsximport { graphql } from 'gql.tada';const PokemonQuery = graphql(`query Pokemon($name: String!) {pokemon(name: $name) {idnametypes}}`);// The result type is fully inferred:// { pokemon: { id: string; name: string; types: string[] } | null }
You use the typed document with any client — urql, Apollo, or a plain fetch wrapper with TanStack Query. The key difference from graphql-codegen is that there's no build step to run. Your types are always up to date because they're computed by TypeScript itself.
When to choose gql.tada vs graphql-codegen
| gql.tada | graphql-codegen client-preset | |
|---|---|---|
| Codegen step | None | Required (CLI or watcher) |
| Types always fresh | Yes (computed by TS) | Only after running codegen |
| Fragment handling | Explicit (pass as argument) | Global (auto-discovered) |
| Persisted documents | Via CLI | Built-in |
| Ecosystem maturity | Newer | Very mature (~5M downloads/week) |
| Editor DX | Auto-complete via TS plugin | Requires codegen run for types |
| Large schemas | Can slow down TS server | No TS performance impact |
Performance note
For very large schemas, gql.tada's type-level inference can slow down the TypeScript language server. If your schema has hundreds of types and deeply nested queries, graphql-codegen may offer a smoother editor experience since the types are pre-computed.
The glue: TanStack Query v5
The shift from hooks to options was enabled by TanStack Query v5, which introduced the queryOptions() helper:
tsimport { queryOptions } from '@tanstack/react-query';const todosOptions = queryOptions({queryKey: ['todos'],queryFn: fetchTodos,staleTime: 5000,});// Use in a hookconst { data } = useQuery(todosOptions);// Use for prefetchingawait queryClient.prefetchQuery(todosOptions);// Use for cache reads — fully typedconst cached = queryClient.getQueryData(todosOptions.queryKey);
This is not just a convenience function. It's a type-safe options factory that ensures queryKey, queryFn, return types, and cache types all stay in sync. It's the primitive that code generators now target instead of generating custom hooks.
The same pattern exists for mutations (mutationOptions()) and infinite queries (infiniteQueryOptions()).
My recommendation for 2026
For REST / OpenAPI
Use @hey-api/openapi-ts with the TanStack Query plugin. The options-based approach is clean, composable, and framework-agnostic. The DX is excellent: you get full type safety from your OpenAPI spec all the way to your component's data property.
openapi-ts.config.tsimport { defineConfig } from '@hey-api/openapi-ts';export default defineConfig({input: './openapi.yaml',output: 'src/client',plugins: ['@hey-api/typescript','@hey-api/sdk','@tanstack/react-query',],});
Add it to your package.json scripts:
json{"scripts": {"codegen:api": "openapi-ts"}}
For GraphQL
If your schema is small to medium sized, try gql.tada. The zero-codegen experience is unbeatable for developer velocity.
If your schema is large, or you need persisted documents and fragment masking in a mature setup, use graphql-codegen with the client preset.
In both cases, avoid the legacy hook-generating plugins. They're community-maintained at best and stale at worst.
Validation layer
Both Hey API and graphql-codegen support generating Zod schemas from your API definitions. This gives you runtime validation on top of compile-time types — useful at system boundaries where you can't trust the data.
ts// openapi-ts.config.ts — add Zod pluginplugins: ['@hey-api/typescript','@hey-api/sdk','@tanstack/react-query','@hey-api/zod', // runtime validation schemas],
The pattern at a glance
Here's the mental model:
REST:
OpenAPI spec →
@hey-api/openapi-ts→ options objects →useQuery({ ...options })
GraphQL (with codegen):
GraphQL schema →
graphql-codegen→ TypedDocumentNode →useQuery(document)
GraphQL (without codegen):
GraphQL schema →
gql.tada→ inferred types →useQuery({ queryFn: () => execute(document) })
All three paths end the same way: a framework-agnostic primitive that plugs into your data-fetching library's existing hooks. The generator handles type safety and API mapping. Your framework handles rendering and state.
That's the separation of concerns that the ecosystem settled on in 2026.
Changelog
This article is refreshed yearly to reflect the latest tools and patterns.
| Year | Key changes |
|---|---|
| 2026 | Initial edition. @hey-api/openapi-ts options pattern, graphql-codegen client preset, gql.tada, shift from hooks to options. |
Sources & Further Reading
- @hey-api/openapi-ts Documentation
Official docs for the leading OpenAPI-to-TypeScript code generator.
- Hey API — TanStack Query Plugin
How to generate queryOptions and mutationOptions from your OpenAPI spec.
- GraphQL Code Generator — Client Preset
The recommended codegen approach for GraphQL in 2026.
- GraphQL Code Generator — v3/v5 Roadmap
The GitHub issue where The Guild explained why they moved away from generating hooks.
- gql.tada Documentation
Type-safe GraphQL without a codegen step, using TypeScript inference.
- TanStack Query v5 — Query Options
The queryOptions() helper that enabled the options-based code generation pattern.
- Orval Documentation
OpenAPI code generator with built-in MSW mock generation.
- Orval — queryOptions Feature Request
Community discussion about adding options-based output to Orval.
