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
Author18 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 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 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/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 (return type is inferred)const 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
The configuration depends on which GraphQL client you use. The key difference is documentMode:
- Default (no
documentMode) generatesTypedDocumentNodeobjects (AST). Apollo Client and urql understand these natively. documentMode: "string"generatesTypedDocumentStringvalues (plain strings with type metadata). This is lighter weight but only works with clients that accept strings, like a customfetchwrapper for TanStack Query.
For Apollo Client or urql (generates TypedDocumentNode):
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: {enumsAsTypes: true,},presetConfig: {fragmentMasking: true,},},},};export default config;
For TanStack Query with a custom fetch wrapper (generates TypedDocumentString):
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;
documentMode matters
Using documentMode: "string" with Apollo Client or urql will cause all your
query results to be typed as any, because those clients expect a
TypedDocumentNode (AST object), not a plain string. If your types are
showing up as any, check this setting first.
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:
bashnpx graphql-codegen --watch
Usage with Apollo Client
Apollo Client natively understands TypedDocumentNode, so the integration is seamless (use the default config without documentMode: "string"):
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. This approach uses documentMode: "string", which generates TypedDocumentString instead of an AST node. The string can be sent directly over fetch:
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
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.
Apollo Client and the client preset
Apollo's documentation notes that the client preset generates additional
runtime code that may be incompatible with Apollo's normalized cache. If you
run into cache issues, consider the legacy typescript +
typescript-operations plugins instead. 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.
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.tsximport { graphql, FragmentType, useFragment } from "../gql";export const FilmCardFragment = graphql(`fragment FilmCard on Film {titlereleaseDatedirector}`);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.tsximport { graphql, FragmentType, useFragment } from "../gql";import FilmCard from "./FilmCard";export const FilmListFragment = graphql(`fragment FilmList on FilmsConnection {totalCountfilms {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.tsximport { 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:
| Layer | Fragment | Owns | Spreads |
|---|---|---|---|
| Page | AllFilmsQuery | — | ...FilmList |
| List | FilmListFragment | id, totalCount | ...FilmCard |
| Card | FilmCardFragment | title, 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 generatedAllFilmsQuery_allFilms_filmstypes, noPick<>gymnastics, no type explosion. - Safe refactoring. If
FilmCardadds aratingfield 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.
FilmsPageliterally cannot accessfilm.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().
When you don't need fragments
Fragment masking shines in component trees with multiple layers. For a simple
page that fetches data and renders it directly, no child components involved,
the inferred type from useQuery is perfectly fine. Don't add fragments to
a flat component just because you can.
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: 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-codegen→TypedDocumentNode→useQuery(document)
GraphQL (codegen + TanStack Query):
GraphQL schema →
graphql-codegen→TypedDocumentString→useQuery({ 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.
| 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.
