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

18 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 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()).

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 (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

The configuration depends on which GraphQL client you use. The key difference is documentMode:

  • Default (no documentMode) generates TypedDocumentNode objects (AST). Apollo Client and urql understand these natively.
  • documentMode: "string" generates TypedDocumentString values (plain strings with type metadata). This is lighter weight but only works with clients that accept strings, like a custom fetch wrapper for TanStack Query.

For Apollo Client or urql (generates TypedDocumentNode):

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: {
enumsAsTypes: true,
},
presetConfig: {
fragmentMasking: true,
},
},
},
};
export default config;

For TanStack Query with a custom fetch wrapper (generates TypedDocumentString):

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;

You need to re-run codegen every time you change a query. During development, run it in watch mode so types stay in sync automatically:

bash
npx graphql-codegen --watch

Usage with Apollo Client

Apollo Client natively understands TypedDocumentNode, so the integration is seamless (use the default config without documentMode: "string"):

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. This approach uses documentMode: "string", which generates TypedDocumentString instead of an AST node. The string can be sent directly over fetch:

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

The client preset supports fragment masking, a pattern where each component declares the data it needs via a fragment, and the type system enforces that only that component can access those fields. We'll cover this in depth in its own section below, because it solves a problem that goes far beyond codegen configuration.

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

Passing typed data to child components

The examples above show the happy path: you call useQuery(AllFilmsQuery) and data is perfectly inferred inside that component. No explicit types needed. But what happens when you need to pass that data down to child components?

This is the question that every typed GraphQL codebase runs into eventually, and the answer matters more than which codegen tool you pick.

The old way: generated types for everything

With the legacy graphql-codegen plugins, every query produced a set of TypeScript types: one for the full result, plus nested types for every level of the response:

ts
// Generated types (legacy approach)
type AllFilmsQuery = { allFilms: { films: AllFilmsQuery_allFilms_films[] } };
type AllFilmsQuery_allFilms_films = { title: string; releaseDate: string };

You'd import these generated types and use them as prop types in child components. It worked, but it created a practical problem: every slightly different query generated its own set of types. A FilmCard that needed title and director got a different type than one that needed title and releaseDate. You'd end up with dozens of nearly identical types, all tightly coupled to specific query shapes, and every query change triggered a cascade of type updates across your components.

Some teams tried wrapping each data-fetch into a custom hook (useFilmCard, useFilmList) to encapsulate the typing. But that just trades a type explosion for a hook explosion: many slightly different hooks that exist only to carry a type, with no actual logic to encapsulate.

The modern way: fragment masking

Fragment masking flips the ownership model. Instead of the query dictating types that flow down, each component declares the data it needs. The type comes from the fragment, not from the query result.

Here's a complete example with three layers: a leaf component, a list component, and a page component:

1. The leaf component declares its data needs:

components/FilmCard.tsx
import { graphql, FragmentType, useFragment } from "../gql";
export const FilmCardFragment = graphql(`
fragment FilmCard on Film {
title
releaseDate
director
}
`);
function FilmCard(props: { film: FragmentType<typeof FilmCardFragment> }) {
const film = useFragment(FilmCardFragment, props.film);
return (
<div>
<h3>{film.title}</h3>
<p>Directed by {film.director}</p>
<time>{film.releaseDate}</time>
</div>
);
}

The prop type FragmentType<typeof FilmCardFragment> is opaque. The parent cannot accidentally access film.director before passing it down. Only FilmCard itself can unwrap the data via useFragment.

2. A mid-level component has its own fragment and composes the child's:

components/FilmList.tsx
import { graphql, FragmentType, useFragment } from "../gql";
import FilmCard from "./FilmCard";
export const FilmListFragment = graphql(`
fragment FilmList on FilmsConnection {
totalCount
films {
id
...FilmCard
}
}
`);
function FilmList(props: { data: FragmentType<typeof FilmListFragment> }) {
const connection = useFragment(FilmListFragment, props.data);
return (
<section>
<h2>{connection.totalCount} films</h2>
<ul>
{connection.films?.map((film) => (
<li key={film?.id}>
<FilmCard film={film} />
</li>
))}
</ul>
</section>
);
}

FilmList spreads ...FilmCard inside its own fragment. It can access id and totalCount (which it declared), but not director or releaseDate (those belong to FilmCard). TypeScript enforces this at compile time.

3. The top-level component owns the query:

pages/FilmsPage.tsx
import { useQuery } from "@apollo/client";
import { graphql } from "../gql";
import FilmList from "../components/FilmList";
const AllFilmsQuery = graphql(`
query AllFilms {
allFilms {
...FilmList
}
}
`);
function FilmsPage() {
const { data, loading } = useQuery(AllFilmsQuery);
if (loading) return <p>Loading…</p>;
if (!data?.allFilms) return <p>No films found</p>;
return <FilmList data={data.allFilms} />;
}

FilmsPage cannot read any film fields at all. The data is fully opaque. It just passes the bag down.

Why this matters

The chain of ownership looks like this:

LayerFragmentOwnsSpreads
PageAllFilmsQuery...FilmList
ListFilmListFragmentid, totalCount...FilmCard
CardFilmCardFragmenttitle, releaseDate, director

This solves the problems that generated types and custom hook wrappers created:

  • No manual types anywhere. Every prop type is FragmentType<typeof XFragment>. No generated AllFilmsQuery_allFilms_films types, no Pick<> gymnastics, no type explosion.
  • Safe refactoring. If FilmCard adds a rating field to its fragment, you re-run codegen and the query automatically includes it. No parent component changes needed.
  • Compile-time boundary enforcement. Each component only sees what it asked for. FilmsPage literally cannot access film.title. TypeScript won't allow it.
  • No hook wrappers needed. The fragment is the type contract. Passing typed data to a child has no logic to encapsulate, so wrapping it in a custom hook would be premature abstraction.

This is the pattern that Relay pioneered. The graphql-codegen client preset brought it to the broader ecosystem without requiring the Relay runtime. gql.tada supports the same idea via readFragment().

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: use fragment masking for component composition. If your component tree has more than one level, fragments are how you pass typed data down without generating types for every query shape or wrapping everything in custom hooks. This applies to both graphql-codegen (via FragmentType + useFragment) and gql.tada (via readFragment).

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 (codegen + Apollo/urql):

GraphQL schema → graphql-codegenTypedDocumentNodeuseQuery(document)

GraphQL (codegen + TanStack Query):

GraphQL schema → graphql-codegenTypedDocumentStringuseQuery({ queryFn: () => execute(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.

For component composition in GraphQL, add one more arrow:

Component fragment → spreads into parent fragment → spreads into query

Each component owns its data requirements via a fragment. Types flow from the leaves up, not from the query down. No generated per-query types, no custom hook wrappers. Just fragments.

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