TanStack Query
Generate queryOptions and mutationOptions for TanStack Query.
TanStack Query
Tangrams generates queryOptions and mutationOptions that snap right into TanStack Query hooks like useQuery, useSuspenseQuery, and useMutation.
Peer Dependencies
Install the required dependencies based on your data source:
Core
bun add @tanstack/react-querynpm install @tanstack/react-querypnpm add @tanstack/react-queryValidation Library
Install your chosen validator (Zod is the default). See Validator Libraries for details.
bun add zodbun add valibotbun add arktypebun add effectGraphQL Sources
bun add graphql-requestnpm install graphql-requestpnpm add graphql-requestOpenAPI Sources
bun add @better-fetch/fetchnpm install @better-fetch/fetchpnpm add @better-fetch/fetchSetup
GraphQL Source
Configure a GraphQL source with either URL-based introspection or local SDL files:
URL-based (introspection):
import { defineConfig } from "tangrams"
export default defineConfig({
sources: [
{
name: "graphql",
type: "graphql",
schema: {
url: "http://localhost:4000/graphql",
// headers: { "x-api-key": process.env.API_KEY },
},
documents: "./src/graphql/**/*.graphql",
generates: ["query"],
},
],
})File-based (local SDL files):
import { defineConfig } from "tangrams"
export default defineConfig({
sources: [
{
name: "graphql",
type: "graphql",
schema: {
file: "./schema.graphql",
// Or multiple files:
// file: ["./schema.graphql", "./extensions/**/*.graphql"],
},
documents: "./src/graphql/**/*.graphql",
generates: ["query"],
},
],
})Then create your GraphQL operations in .graphql files:
# src/graphql/user.graphql
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
}
}OpenAPI Source
Configure an OpenAPI source with a local file or remote URL:
import { defineConfig } from "tangrams"
export default defineConfig({
sources: [
{
name: "api",
type: "openapi",
spec: "./openapi.yaml", // or a remote URL
generates: ["query"],
},
],
})With path filtering:
{
name: "api",
type: "openapi",
spec: "./openapi.yaml",
include: ["/users/**", "/posts/**"],
exclude: ["/internal/**"],
generates: ["query"],
}Generate
Run the generator:
bunx tangrams generateGenerated Output
Output Directory Structure
tangrams/
└── <source>/
├── client.ts # API client (shared)
├── schema.ts # Validation schemas + TypeScript types
├── functions.ts # Standalone fetch functions (auto-generated)
└── query/
└── options.ts # queryOptions and mutationOptionsGraphQL Output
client.ts - A configured graphql-request client:
import { GraphQLClient } from "graphql-request"
const endpoint = "http://localhost:4000/graphql"
export const getClient = async () => {
return new GraphQLClient(endpoint, {
headers: {
// Add your headers here
},
})
}schema.ts - Validation schemas and TypeScript types from your schema and operations (shown with Zod, the default):
import * as z from "zod"
// Operation variable schemas
export const getUserQueryVariablesSchema = z.object({
id: z.string(),
})
// Operation response schemas
export const getUserQuerySchema = z.object({
user: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}).nullable(),
})
// TypeScript Types (inferred from Zod schemas)
export type GetUserQueryVariables = z.infer<typeof getUserQueryVariablesSchema>
export type GetUserQuery = z.infer<typeof getUserQuerySchema>options.ts - Ready-to-use query and mutation options:
import { queryOptions, mutationOptions } from "@tanstack/react-query"
import { getUser, createUser } from "../functions"
import type { GetUserQueryVariables, CreateUserMutationVariables } from "../schema"
export const getUserQueryOptions = (variables: GetUserQueryVariables) =>
queryOptions({
queryKey: ["graphql", "GetUser", variables],
queryFn: () => getUser(variables),
})
export const createUserMutationOptions = () =>
mutationOptions({
mutationKey: ["graphql", "CreateUser"],
mutationFn: (variables: CreateUserMutationVariables) => createUser(variables),
})OpenAPI Output
<source>/schema.ts - Validation schemas and inferred TypeScript types (shown with Zod, the default):
import * as z from "zod"
export const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
})
export const createUserRequestSchema = z.object({
name: z.string(),
email: z.string(),
})
// Inferred TypeScript types
export type User = z.infer<typeof userSchema>
export type CreateUserRequest = z.infer<typeof createUserRequestSchema>client.ts - A configured better-fetch client with async getClient function:
import { createFetch } from "@better-fetch/fetch"
const baseURL = "https://api.example.com"
/**
* Returns a configured fetch client.
* Customize this function to add dynamic headers (e.g., auth tokens).
*/
export const getClient = async () => {
return createFetch({
baseURL,
headers: {
// Add your headers here
},
})
}options.ts - Query and mutation options with schema validation:
export const listUsersQueryOptions = (params?: ListUsersParams) =>
queryOptions({
queryKey: ["api", "listUsers", params],
queryFn: async () => {
const { data, error } = await $fetch<ListUsersResponse>("/users", {
output: listUsersResponseSchema,
})
if (error) throw error
return data
},
})
export const createUserMutationOptions = () =>
mutationOptions({
mutationKey: ["api", "createUser"],
mutationFn: async (body: CreateUserRequest) => {
const { data, error } = await $fetch<User>("/users", {
method: "POST",
output: userSchema,
body,
})
if (error) throw error
return data
},
})Usage
The generated options snap right into TanStack Query hooks:
import { useQuery, useSuspenseQuery, useMutation } from "@tanstack/react-query"
import {
getUserQueryOptions,
createUserMutationOptions,
} from "./tangrams/my-api/query/options"
function UserProfile({ userId }: { userId: string }) {
// useQuery with full type inference
const { data, isLoading } = useQuery(getUserQueryOptions({ id: userId }))
if (isLoading) return <div>Loading...</div>
return <div>{data?.user?.name}</div>
}
function UserProfileSuspense({ userId }: { userId: string }) {
// useSuspenseQuery - data is never undefined
const { data } = useSuspenseQuery(getUserQueryOptions({ id: userId }))
return <div>{data.user?.name}</div>
}
function CreateUserButton() {
const { mutate, isPending } = useMutation(createUserMutationOptions())
return (
<button
disabled={isPending}
onClick={() => mutate({ input: { name: "Jane", email: "jane@example.com" } })}
>
{isPending ? "Creating..." : "Create User"}
</button>
)
}Configuration Reference
GraphQL Source Options
| Option | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique name for this source (lowercase alphanumeric with hyphens, starting with a letter) |
type | "graphql" | Yes | Source type |
schema | object | Yes | Schema configuration (see below) |
documents | string | string[] | Yes | Glob pattern(s) for .graphql operation files |
generates | array | Yes | What to generate: ["query"], ["query", "form"], ["db"], etc. |
overrides | object | No | Override scalars, DB, and form settings (see Configuration Reference) |
Schema Configuration
URL-based (introspection):
| Option | Type | Required | Description |
|---|---|---|---|
schema.url | string | Yes | GraphQL endpoint URL for introspection |
schema.headers | Record<string, string> | No | Headers to send with introspection request |
File-based (local SDL files):
| Option | Type | Required | Description |
|---|---|---|---|
schema.file | string | string[] | Yes | Path or glob pattern(s) for .graphql schema files |
OpenAPI Source Options
| Option | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique name for this source (lowercase alphanumeric with hyphens, starting with a letter) |
type | "openapi" | Yes | Source type |
spec | string | Yes | Path to OpenAPI spec (local file or URL) |
headers | Record<string, string> | No | Headers for fetching remote spec |
include | string[] | No | Glob patterns for paths to include |
exclude | string[] | No | Glob patterns for paths to exclude |
generates | array | Yes | What to generate: ["query"], ["query", "form"], ["db"], etc. |
overrides | object | No | Override DB and form settings (see Configuration Reference) |
Custom Scalars (GraphQL)
Map custom GraphQL scalars to validator expressions. Values must be valid expressions for your configured validator:
{
name: "graphql",
type: "graphql",
schema: { url: "..." },
documents: "...",
generates: ["query"],
overrides: {
scalars: {
DateTime: "z.string()",
Cursor: "z.string()",
},
},
}{
name: "graphql",
type: "graphql",
schema: { url: "..." },
documents: "...",
generates: ["query"],
overrides: {
scalars: {
DateTime: "v.string()",
Cursor: "v.string()",
},
},
}{
name: "graphql",
type: "graphql",
schema: { url: "..." },
documents: "...",
generates: ["query"],
overrides: {
scalars: {
DateTime: 'type("string")',
Cursor: 'type("string")',
},
},
}{
name: "graphql",
type: "graphql",
schema: { url: "..." },
documents: "...",
generates: ["query"],
overrides: {
scalars: {
DateTime: "Schema.String",
Cursor: "Schema.String",
},
},
}Note: Scalar values must start with a valid prefix for your validator:
z.for Zod,v.for Valibot,type(/type.for ArkType, orSchema.for Effect.
Built-in Scalar Mappings
The following GraphQL scalars are handled automatically and don't need custom mappings:
| GraphQL Scalar | Generated Schema |
|---|---|
ID | z.string() |
String | z.string() |
Int | z.number() |
Float | z.number() |
Boolean | z.boolean() |
DateTime | z.string() |
Date | z.string() |
JSON | z.unknown() |
BigInt | z.bigint() |
UUID | z.string() |
Infinite Query Options
Tangrams automatically generates infiniteQueryOptions for paginated queries alongside the regular queryOptions. This enables seamless integration with TanStack Query's useInfiniteQuery hook for "load more" or "infinite scroll" patterns.
Supported Pagination Styles
Tangrams detects pagination patterns from your schema and generates the appropriate getNextPageParam logic:
| Style | Parameters | Response Fields | Example |
|---|---|---|---|
| Cursor | cursor, after | nextCursor, endCursor | GraphQL Relay connections |
| Offset | offset + limit | total | REST APIs with total count |
| Page | page + pageSize/perPage | totalPages | Traditional pagination |
| Relay | first/last + after/before | pageInfo.hasNextPage, pageInfo.endCursor | GraphQL Relay spec |
Generated Output
For a paginated operation like listPets with offset pagination:
// Regular query options (always generated)
export const listPetsQueryOptions = (params?: ListPetsParams) =>
queryOptions({
queryKey: ["api", "listPets", params],
queryFn: () => listPets(params),
})
// Infinite query options (auto-generated for paginated operations)
export const listPetsInfiniteQueryOptions = (params?: Omit<ListPetsParams, "offset">) =>
infiniteQueryOptions({
queryKey: ["api", "listPets", "infinite", params],
queryFn: ({ pageParam }) => listPets({ ...params, offset: pageParam }),
initialPageParam: 0,
getNextPageParam: (lastPage, _allPages, lastPageParam) =>
(lastPageParam ?? 0) + (params?.limit ?? 20) < lastPage.total
? (lastPageParam ?? 0) + (params?.limit ?? 20)
: undefined,
})For Relay-style cursor pagination (GraphQL):
export const getPetsConnectionInfiniteQueryOptions = (
variables?: Omit<GetPetsConnectionQueryVariables, "after">
) =>
infiniteQueryOptions({
queryKey: ["api", "GetPetsConnection", "infinite", variables],
queryFn: ({ pageParam }) => getPetsConnection({ ...variables, after: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.petsConnection?.pageInfo?.hasNextPage
? lastPage.petsConnection?.pageInfo?.endCursor
: undefined,
})Usage
import { useInfiniteQuery } from "@tanstack/react-query"
import { listPetsInfiniteQueryOptions } from "./tangrams/api/query/options"
function PetList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery(listPetsInfiniteQueryOptions({ limit: 10 }))
return (
<div>
{data?.pages.map((page) =>
page.data.map((pet) => <PetCard key={pet.id} pet={pet} />)
)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? "Loading..." : hasNextPage ? "Load More" : "No more pets"}
</button>
</div>
)
}Configuration
You can customize or disable infinite query generation per operation:
import { defineConfig } from "tangrams"
export default defineConfig({
sources: [
{
name: "api",
type: "openapi",
spec: "./openapi.yaml",
generates: ["query"],
overrides: {
query: {
operations: {
// Custom getNextPageParam path
listPets: {
getNextPageParamPath: "meta.pagination.nextCursor",
},
// Disable infinite query for specific operation
searchUsers: {
disabled: true,
},
},
},
},
},
],
})| Option | Type | Description |
|---|---|---|
getNextPageParamPath | string | Dot-notation path to the next page cursor in the response (e.g., "meta.nextCursor") |
initialPageParam | string | number | Override the initial page parameter value |
disabled | boolean | Disable infinite query generation for this operation |
Requirements
For automatic infinite query generation, Tangrams needs to detect:
- Pagination parameters in the request (e.g.,
offset,cursor,page,after) - Pagination response fields that indicate how to get the next page (e.g.,
total,nextCursor,hasNextPage)
If Tangrams cannot detect the response structure, it will skip infinite query generation and log a warning. You can use overrides.query.operations.<operationName>.getNextPageParamPath to explicitly configure the path.
TanStack DB Integration
Tangrams can also generate TanStack DB collections for local-first data synchronization with optimistic updates. When "db" is specified in generates, query generation is automatically enabled as well.
For more details, see the TanStack DB guide.
