2026

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.

S
Sascha Becker
Author

12 min read

Typesafe API Code Generation for React in 2026

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 / infiniteQueryOptions functions 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.ts
import { 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:

bash
npx @hey-api/openapi-ts

Generated output

The generator produces option functions — plain functions returning objects:

ts
// Generated: src/client/@tanstack/react-query.gen.ts
export 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:

tsx
import { 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 inferred
const cached = queryClient.getQueryData(
getPetByIdQueryKey({ path: { petId } })
);
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.ts
import { 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-tsOrval
OutputOptions objectsCustom hooks
TanStack QueryReact, Vue, Svelte, Solid, AngularReact, Vue, Svelte, Solid
Mock generationSeparate pluginBuilt-in (MSW + Faker.js)
ValidationZod, ValibotZod
HTTP clientsFetch, Axios, Ky, Next.js, NuxtAxios, Fetch
MaturityPre-1.0, fast-movingv8, 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:

  1. Use the client preset to generate a typed graphql() function
  2. Write your queries inline using that function
  3. Pass the typed documents to your client library's own hooks

Configuration

codegen.ts
import 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:

tsx
import { useQuery } from '@apollo/client';
import { graphql } from './gql';
const AllFilmsQuery = graphql(`
query AllFilms {
allFilms {
films {
title
releaseDate
}
}
}
`);
function Films() {
// data is fully typed — no generics needed
const { 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:

ts
import 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:

tsx
import { useQuery } from '@tanstack/react-query';
import { graphql } from './gql';
import { execute } from './graphql-client';
const PeopleQuery = graphql(`
query AllPeople {
allPeople {
totalCount
people {
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:

tsx
import { graphql, FragmentType, useFragment } from './gql';
const FilmCardFragment = graphql(`
fragment FilmCard on Film {
title
releaseDate
director
}
`);
function FilmCard(props: {
film: FragmentType<typeof FilmCardFragment>;
}) {
const film = useFragment(FilmCardFragment, props.film);
// film.title — typed and accessible
// film.id — compile error, not in fragment
return <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.

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

  1. Your schema is loaded via a TypeScript plugin
  2. The plugin generates a graphql-env.d.ts type file
  3. When you write graphql('query { ... }'), TypeScript parses the query string at the type level
  4. Result and variable types are inferred — no build step, no watcher

Setup

bash
npm install gql.tada
tsconfig.json
{
"compilerOptions": {
"plugins": [
{
"name": "gql.tada/ts-plugin",
"schema": "./schema.graphql",
"tadaOutputLocation": "./src/graphql-env.d.ts"
}
]
}
}

Usage

tsx
import { graphql } from 'gql.tada';
const PokemonQuery = graphql(`
query Pokemon($name: String!) {
pokemon(name: $name) {
id
name
types
}
}
`);
// 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.tadagraphql-codegen client-preset
Codegen stepNoneRequired (CLI or watcher)
Types always freshYes (computed by TS)Only after running codegen
Fragment handlingExplicit (pass as argument)Global (auto-discovered)
Persisted documentsVia CLIBuilt-in
Ecosystem maturityNewerVery mature (~5M downloads/week)
Editor DXAuto-complete via TS pluginRequires codegen run for types
Large schemasCan slow down TS serverNo TS performance impact

The glue: TanStack Query v5

The shift from hooks to options was enabled by TanStack Query v5, which introduced the queryOptions() helper:

ts
import { queryOptions } from '@tanstack/react-query';
const todosOptions = queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5000,
});
// Use in a hook
const { data } = useQuery(todosOptions);
// Use for prefetching
await queryClient.prefetchQuery(todosOptions);
// Use for cache reads — fully typed
const 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.ts
import { 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 plugin
plugins: [
'@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.

YearKey changes
2026Initial edition. @hey-api/openapi-ts options pattern, graphql-codegen client preset, gql.tada, shift from hooks to options.

Sources & Further Reading


S
Written by
Sascha Becker
More articles