Tangramsangrams

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-query
npm install @tanstack/react-query
pnpm add @tanstack/react-query

Validation Library

Install your chosen validator (Zod is the default). See Validator Libraries for details.

bun add zod
bun add valibot
bun add arktype
bun add effect

GraphQL Sources

bun add graphql-request
npm install graphql-request
pnpm add graphql-request

OpenAPI Sources

bun add @better-fetch/fetch
npm install @better-fetch/fetch
pnpm add @better-fetch/fetch

Setup

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 generate

Generated 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 mutationOptions

GraphQL 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

OptionTypeRequiredDescription
namestringYesUnique name for this source (lowercase alphanumeric with hyphens, starting with a letter)
type"graphql"YesSource type
schemaobjectYesSchema configuration (see below)
documentsstring | string[]YesGlob pattern(s) for .graphql operation files
generatesarrayYesWhat to generate: ["query"], ["query", "form"], ["db"], etc.
overridesobjectNoOverride scalars, DB, and form settings (see Configuration Reference)

Schema Configuration

URL-based (introspection):

OptionTypeRequiredDescription
schema.urlstringYesGraphQL endpoint URL for introspection
schema.headersRecord<string, string>NoHeaders to send with introspection request

File-based (local SDL files):

OptionTypeRequiredDescription
schema.filestring | string[]YesPath or glob pattern(s) for .graphql schema files

OpenAPI Source Options

OptionTypeRequiredDescription
namestringYesUnique name for this source (lowercase alphanumeric with hyphens, starting with a letter)
type"openapi"YesSource type
specstringYesPath to OpenAPI spec (local file or URL)
headersRecord<string, string>NoHeaders for fetching remote spec
includestring[]NoGlob patterns for paths to include
excludestring[]NoGlob patterns for paths to exclude
generatesarrayYesWhat to generate: ["query"], ["query", "form"], ["db"], etc.
overridesobjectNoOverride 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, or Schema. for Effect.

Built-in Scalar Mappings

The following GraphQL scalars are handled automatically and don't need custom mappings:

GraphQL ScalarGenerated Schema
IDz.string()
Stringz.string()
Intz.number()
Floatz.number()
Booleanz.boolean()
DateTimez.string()
Datez.string()
JSONz.unknown()
BigIntz.bigint()
UUIDz.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:

StyleParametersResponse FieldsExample
Cursorcursor, afternextCursor, endCursorGraphQL Relay connections
Offsetoffset + limittotalREST APIs with total count
Pagepage + pageSize/perPagetotalPagesTraditional pagination
Relayfirst/last + after/beforepageInfo.hasNextPage, pageInfo.endCursorGraphQL 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,
            },
          },
        },
      },
    },
  ],
})
OptionTypeDescription
getNextPageParamPathstringDot-notation path to the next page cursor in the response (e.g., "meta.nextCursor")
initialPageParamstring | numberOverride the initial page parameter value
disabledbooleanDisable infinite query generation for this operation

Requirements

For automatic infinite query generation, Tangrams needs to detect:

  1. Pagination parameters in the request (e.g., offset, cursor, page, after)
  2. 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.

On this page