---
title: "TanStack Query Integration"
description: "Integrate oRPC with TanStack Query using utilities for query and mutation options, keys, infinite and streamed queries, and typesafe errors."
sidebar:
  label: "Tanstack Query"
---

:::warning
This guide assumes you are already familiar with [TanStack Query](https://tanstack.com/query/latest). If you need a refresher, review the official TanStack Query documentation before continuing.
:::

## Installation

```package-install
npm install @orpc/tanstack-query@beta
```

## Setup

Before you begin, set up either a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side).

```ts twoslash
import { client } from './shared/planet'
// ---cut---
import { createTanstackQueryUtils } from '@orpc/tanstack-query'

const orpc = createTanstackQueryUtils(client)

orpc.planet.find.queryOptions({ input: { id: 123 } })
//               ^|

//

//

//

//

//

//
```

**Avoiding Query and Mutation Key Conflicts?**

To avoid key conflicts when creating multiple sets of utils, pass a unique `prefix`. It becomes the first element of every query/mutation key, so keys from different utils never overlap.

```ts
const userORPC = createTanstackQueryUtils(userClient, {
  prefix: 'user'
})

const postORPC = createTanstackQueryUtils(postClient, {
  prefix: 'post'
})
```

## Query Options

Use `.queryOptions` to build query options. It works with `useQuery`, `useSuspenseQuery`, and `prefetchQuery`, and any other API that accepts query options.

```ts
const query = useQuery(orpc.planet.find.queryOptions({
  input: { id: 123 }, // Specify input if needed
  context: { cache: true }, // Provide client context if needed
  // additional options...
}))
```

## Streamed Query Options

Use `.streamedOptions` to build query options for an [AsyncIteratorObject](/docs/async-iterator-object). The data is an array of events, and each new event is appended to it. It works with `useQuery`, `useSuspenseQuery`, `prefetchQuery`, and any other API that accepts query options.

```ts
const query = useQuery(orpc.streamed.streamedOptions({
  input: { id: 123 },
  queryFnOptions: {
    refetchMode: 'append',
    maxChunks: 3, // Keep only the last 3 events
  },
  retry: true, // Retry forever to keep the stream alive
}))
```

:::info
On refetch, existing data stays until the new stream connects. Then `refetchMode` decides:

- `'reset'` _(default)_: data becomes `[]`.
- `'append'`: data is kept, trimmed to `maxChunks`.

For a loading state, call `queryClient.resetQueries({ queryKey })` before refetching.
:::

:::warning
`maxChunks` is part of the query key (`refetchMode` is not):

- Queries with different `maxChunks` get separate cache entries and separate streams. To share one, use the same `maxChunks` and trim with `select`, e.g. `select: data => data.slice(-3)`.
- To read or write the cache by key, pass the same `maxChunks`: `streamedKey({ input, queryFnOptions: { maxChunks } })`.
:::

## Live Query Options

Use `.liveOptions` to build query options for an [AsyncIteratorObject](/docs/async-iterator-object). The data is always the latest event. An `undefined` event rejects the fetch, since TanStack Query cannot store `undefined`.

```ts
const query = useQuery(orpc.live.liveOptions({
  input: { id: 123 },
  retry: true, // Retry forever to keep the stream alive
}))
```

### Settle on First Data

By default, a streamed or live query follows its stream until it ends. So awaiting an endless stream (`fetchQuery`, `ensureQueryData`, route loaders) never resolves.

Pass `settleOnFirstData` to settle the fetch at the first data instead: when a streamed query connects, or on a live query's first event. The stream closes and the data stays, marked stale. The stream reopens once the query is observed, for example on mount.

- `true`: always settle.
- `'unobserved'`: settle only if nothing observes the query, such as a mounted `useQuery`.
- `false` _(default)_: never settle.

```ts
const query = useSuspenseQuery(orpc.live.liveOptions({
  input: { id: 123 },
  settleOnFirstData: 'unobserved',
}))
```

- Not part of the query key, and ignored with a custom `queryFn`.
- A stream cut short (cancelled, or its last observer unmounts) is marked stale, so it reopens when observed again.

## Infinite Query Options

Use `.infiniteOptions` to build infinite query options. It works with `useInfiniteQuery`, `useSuspenseInfiniteQuery`, and `prefetchInfiniteQuery`, and any other API that accepts infinite query options.

:::info
The `input` option must be a function that receives the page parameter and returns the query input. Define the `pageParam` type explicitly if it can be `null` or `undefined`.
:::

```ts
const query = useInfiniteQuery(orpc.planet.list.infiniteOptions({
  input: (pageParam: number | undefined) => ({ limit: 10, offset: pageParam }),
  context: { cache: true }, // Provide client context if needed
  initialPageParam: undefined,
  getNextPageParam: lastPage => lastPage.nextPageParam,
  // additional options...
}))
```

## Mutation Options

Use `.mutationOptions` to build mutation options. It works with `useMutation` and any other API that accepts mutation options.

```ts
const mutation = useMutation(orpc.planet.create.mutationOptions({
  context: { cache: true }, // Provide client context if needed
  // additional options...
}))

mutation.mutate({ name: 'Earth' })
```

## Query and Mutation Keys

oRPC provides helper methods for generating query and mutation keys:

- `.key`: Generates a **partial-match** key for actions such as invalidating queries or checking mutation status.
- `.queryKey`: Generates a **full-match** key for [Query Options](#query-options).
- `.streamedKey`: Generates a **full-match** key for [Streamed Query Options](#streamed-query-options).
- `.liveKey`: Generates a **full-match** key for [Live Query Options](#live-query-options).
- `.infiniteKey`: Generates a **full-match** key for [Infinite Query Options](#infinite-query-options).
- `.mutationKey`: Generates a **full-match** key for [Mutation Options](#mutation-options).

```ts
const queryClient = useQueryClient()

// Invalidate all planet queries
queryClient.invalidateQueries({
  queryKey: orpc.planet.key(),
})

// Invalidate only regular (non-infinite) planet queries
queryClient.invalidateQueries({
  queryKey: orpc.planet.key({ type: 'query' })
})

// Invalidate the planet find query with id 123
queryClient.invalidateQueries({
  queryKey: orpc.planet.find.key({ input: { id: 123 } })
})

// Update the planet find query with id 123
queryClient.setQueryData(orpc.planet.find.queryKey({ input: { id: 123 } }), (old) => {
  return { ...old, id: 123, name: 'Earth' }
})
```

### Key Serializer

oRPC serializes inputs with the [RPC JSON Serializer](/docs/rpc/serializer#rpc-json-serializer) when building keys, so inputs like `Date`, `BigInt`, `Set`, and `Map` match by value, and a `Date` and its ISO string produce different keys. You can pass a custom [RPC JSON Serializer](/docs/rpc/serializer#rpc-json-serializer), or an [OpenAPI JSON Serializer](/docs/openapi/serializer#openapi-json-serializer) if you don't need type-aware keys:

```ts
const orpc = createTanstackQueryUtils(client, { serializer })
```

## Calling Clients

The `.call` method provides direct access to the underlying procedure client when needed.

```ts
const planet = await orpc.planet.find.call({ id: 123 })
```

## Reactive Options

In reactive libraries like Vue or Solid, TanStack Query supports passing computed values as options. The exact API varies by framework, so refer to the TanStack Query documentation for [Vue](https://tanstack.com/query/latest/docs/framework/vue/reactivity) or [Solid](https://tanstack.com/query/latest/docs/framework/solid/reference/useQuery#reactive-options).

```ts title="Options as Function"
const query = useQuery(
  () => orpc.planet.find.queryOptions({
    input: { id: id() },
  })
)
```

```ts title="Computed Options"
const query = useQuery(computed(
  () => orpc.planet.find.queryOptions({
    input: { id: id.value },
  })
))
```

## Default Options

Use `scoped` to configure default options for scoped query and mutation utilities. Each value can be either a partial options object, which is spread-merged with lower priority than per-call options, or a function that receives the per-call options and returns the merged result.

```ts
const orpc = createTanstackQueryUtils(client, {
  scoped: {
    planet: {
      find: {
        queryKey: options => ({
          // Override the auto-generated query key for .queryKey and .queryOptions
          queryKey: options.queryKey ?? ['planet', 'find', options.input]
        }),
        queryOptions: {
          staleTime: 60 * 1000, // 1 minute
          retry: 3,
        },
      },
      list: {
        infiniteOptions: options => ({
          ...options,
          staleTime: 30 * 1000, // override takes priority
        }),
      },
      create: {
        mutationOptions: {
          onSuccess: (output, input, _, ctx) => {
            ctx.client.invalidateQueries({ queryKey: orpc.planet.key() })
          },
        },
      },
    },
  },
})

// These calls automatically use the default options
const query = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 } }))
const mutation = useMutation(orpc.planet.create.mutationOptions())

// User-provided options take precedence
const customQuery = useQuery(orpc.planet.find.queryOptions({
  input: { id: 123 },
  staleTime: 0, // overrides the default staleTime
}))
```

:::info
When you configure `queryKey`, it also affects `.queryOptions` because it is used internally to generate query keys. The same applies to live, streamed, infinite, and mutation options when you configure their keys. Custom keys skip the [key serializer](#key-serializer), so keep them JSON-compatible.
:::

## Interceptors

Interceptors let you wrap `queryFn` and `mutationFn` calls. Unlike [default options](#default-options), which can be overridden by per-call options, interceptors always run for every query and mutation.

```ts
import { isDefinedError, safe } from '@orpc/client'

const orpc = createTanstackQueryUtils(client, {
  queryInterceptors: [],
  liveInterceptors: [],
  streamedInterceptors: [],
  infiniteInterceptors: [],
  mutationInterceptors: [
    async ({ context, path, next }) => {
      const [error, data] = await safe(next())

      if (error) {
        if (isDefinedError(error)) {
          // handle typesafe errors
        }

        throw error
      }

      return data
    }
  ],
  scoped: {
    planet: {
      create: {
        mutationInterceptors: [
          async ({ next, fnContext }) => {
            const result = await next()
            fnContext.client.invalidateQueries({ queryKey: orpc.planet.key() })
            return result
          },
        ],
      },
    },
  },
})
```

:::info
You can use [`safe` and `isDefinedError`](/docs/client/error-handling#using-safe-and-isdefinederror) together for typesafe error handling in interceptors.
:::

## Plugins

Plugins package reusable defaults and interceptors for queries and mutations.

```ts
const orpc = createTanstackQueryUtils(client, {
  plugins: []
})
```

### Contract Options Plugin

Use `tanstackQuery` to define base options and interceptors directly on a [procedure contract](/docs/contract/procedure), then pass the contract to `ContractOptionsUtilsPlugin` to apply them automatically. Meta options act as the base layer: [default options](#default-options) and [interceptors](#interceptors) defined on the utils merge on top of them. Passing `undefined` explicitly for a key resets the value from lower layers instead of merging.

```ts
import { ContractOptionsUtilsPlugin, tanstackQuery } from '@orpc/tanstack-query'

export const contract = {
  planet: {
    find: oc
      .input(z.object({ id: z.number() }))
      .meta(tanstackQuery({
        queryOptions: {
          staleTime: 60 * 1000,
        },
        queryInterceptors: [
          async ({ input, next }) => {
            // input, output, and errors are typed based on the contract
            return await next()
          },
        ],
      })),
  },
}

const orpc = createTanstackQueryUtils(client, {
  plugins: [new ContractOptionsUtilsPlugin(contract)],
})
```

:::warning
Types inferred from the contract are for reference only. The actual types depend on the client the utils are created from. For example, a `JsonifiedClient` created from [OpenAPI Link](/docs/openapi/link#typesafe-clients) returns jsonified outputs that may not match the contract schemas.
:::

**Passing runtime values into contract meta?**

Contracts are defined separately from your app, so anything inside `tanstackQuery` cannot import runtime values such as your router utils. Instead, [register a global meta type](https://tanstack.com/query/latest/docs/framework/react/typescript#registering-global-meta) and pass the values through the `meta` option, per hook or globally via query client default options. The example below reads router utils from `fnContext.meta` to optimistically update a query:

```ts
import type { RouterContractClient } from '@orpc/contract'
import type { RouterUtils } from '@orpc/tanstack-query'

declare module '@tanstack/react-query' {
  interface Register {
    mutationMeta: {
      utils?: RouterUtils<RouterContractClient<typeof contract>>
    }
  }
}

export const contract = {
  planet: {
    find: oc.input(z.object({ id: z.number() })),
    update: oc
      .input(z.object({ id: z.number(), name: z.string() }))
      .meta(tanstackQuery({
        mutationInterceptors: [
          async ({ input, next, fnContext }) => {
            const utils = fnContext.meta?.utils

            if (!utils) {
              return next()
            }

            const queryKey = utils.planet.find.queryKey({ input: { id: input.id } })
            const previous = fnContext.client.getQueryData(queryKey)

            // optimistically update before the request
            fnContext.client.setQueryData(queryKey, input)

            try {
              return await next()
            }
            catch (error) {
              // roll back on error
              fnContext.client.setQueryData(queryKey, previous)
              throw error
            }
            finally {
              fnContext.client.invalidateQueries({ queryKey })
            }
          },
        ],
      })),
  },
}

const queryClient = new QueryClient({
  defaultOptions: {
    mutations: {
      meta: { utils: orpc },
    },
  },
})
```

## Client Context

:::warning
oRPC excludes [client context](/docs/client/client-side#client-context) from query keys. Override the query key manually when you need to prevent unintended query deduplication.

```ts
const query = useQuery(orpc.planet.find.queryOptions({
  context: { cache: true },
  // manually include context in the query key
  queryKey: [['planet', 'find'], { context: { cache: true } }],
  // additional options...
}))
```

:::

When a client is invoked through the TanStack Query integration, an **operation context** is automatically added to the [client context](/docs/client/client-side#client-context). You can use this context to configure request behavior, such as selecting the HTTP method for [RPC Link](/docs/rpc/link#request-method).

```ts twoslash
import { RPCLink } from '@orpc/client/fetch'
// ---cut---
import {
  TANSTACK_QUERY_OPERATION_CONTEXT_SYMBOL,
  TanstackQueryOperationContext,
} from '@orpc/tanstack-query'

interface ClientContext extends TanstackQueryOperationContext {
}

const GET_OPERATION_TYPE = new Set(['query', 'streamed', 'live', 'infinite'])

const link = new RPCLink<ClientContext>({
  method: ({ context }) => {
    const operationType = context[TANSTACK_QUERY_OPERATION_CONTEXT_SYMBOL]?.type

    if (operationType && GET_OPERATION_TYPE.has(operationType)) {
      return 'GET'
    }

    return 'POST'
  },
})
```

## Typesafe Error Handling

Use the built-in `isDefinedError` helper to handle [typesafe errors](/docs/error-handling#typesafe-errors) in queries and mutations.

```ts
import { isDefinedError } from '@orpc/client'

const mutation = useMutation(orpc.planet.create.mutationOptions({
  onError: (error) => {
    if (isDefinedError(error)) {
      // Handle typesafe errors here
    }
  }
}))

mutation.mutate({ name: 'Earth' })

if (mutation.error && isDefinedError(mutation.error)) {
  // Handle the typesafe errors here
}
```

## `skipToken` for Disabling Queries

The [skipToken symbol](https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries#typesafe-disabling-of-queries-using-skiptoken) provides a typesafe alternative to setting `enabled: false` when you want to disable a query by omitting its `input`.

```ts
const query = useQuery(
  orpc.planet.list.queryOptions({
    input: search ? { search } : skipToken, // [!code highlight]
  })
)

const query = useInfiniteQuery(
  orpc.planet.list.infiniteOptions({
    input: search // [!code highlight]
      ? (offset: number | undefined) => ({ limit: 10, offset, search }) // [!code highlight]
      : skipToken, // [!code highlight]
    initialPageParam: undefined,
    getNextPageParam: lastPage => lastPage.nextPageParam,
  })
)
```

## Structural Sharing

`.queryOptions`, `.liveOptions`, and `.infiniteOptions` use oRPC's structural sharing by default, and this per-query setting takes precedence over `defaultOptions.queries.structuralSharing` on the `QueryClient`. Unlike TanStack Query's default [structural sharing](https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations#structural-sharing), it also keeps unchanged `Date`, `URL`, `Map`, and `Set` values, so refetches and live events that return the same data do not cause re-renders.

```ts
import { createStructuralSharing } from '@orpc/tanstack-query'

// Opt out per call
orpc.planet.list.queryOptions({ input: { limit: 10 }, structuralSharing: false })

// Use it for queries not built with oRPC utils
const queryClient = new QueryClient({
  defaultOptions: { queries: { structuralSharing: createStructuralSharing() } },
})
```

With an RPC [key serializer](#key-serializer), equal instances of [custom types](/docs/rpc/serializer#custom-serializers) are kept too, compared by what their handler serializes them to. For queries not built with oRPC utils, pass it to `createStructuralSharing(serializer)`.

## Server-Side Rendering (SSR)

When [server-side rendering](/docs/recipes/optimizing-ssr) with [TanStack Query hydration](https://tanstack.com/query/latest/docs/framework/react/guides/ssr), two things need special care: oRPC types that JSON cannot represent, and streamed or live queries that never complete.

### Custom Serializers

TanStack Query dehydrates query data as plain JSON, which cannot represent oRPC types such as `Date`, `BigInt`, `Set`, or `Map`. Pass your [RPC Serializer](/docs/rpc/serializer#custom-serializers) to `createHydrationOptions` to keep them intact.

```ts
import { createHydrationOptions } from '@orpc/tanstack-query'
import { QueryClient } from '@tanstack/react-query' // or any framework adapter, e.g. @tanstack/vue-query

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // > 0 to prevent immediate refetching on mount
    },
    ...createHydrationOptions(serializer), // dehydrate.serializeData and hydrate.deserializeData
  },
})
```

### Streamed and Live Queries

Add `StreamQuerySSRUtilsPlugin` so [streamed](#streamed-query-options) and [live](#live-query-options) queries render on the server without blocking forever.

```ts
import { StreamQuerySSRUtilsPlugin } from '@orpc/tanstack-query'

export const orpc = createTanstackQueryUtils(client, {
  plugins: [new StreamQuerySSRUtilsPlugin()],
})
```

It sets the [`settleOnFirstData`](#settle-on-first-data) option of `.streamedOptions` and `.liveOptions`:

- Server: `true`. The query is dehydrated with its first data.
- Browser: `'unobserved'`. A mounted `useQuery` follows the stream, as does `suspense()` in Vue, Svelte, and Solid.

In the browser it also sets `refetchOnMount: 'always'`, so after hydration, mounting reopens the stream (even with a `staleTime`) while the hydrated data stays on screen. A finished finite stream also reruns on each mount; pass `refetchOnMount: true` to respect `staleTime` instead. On the server it sets `refetchOnMount: false`, so components sharing a settled query do not reopen it.

Per-call and [default options](#default-options) override the plugin. The server is detected with TanStack Query's `environmentManager.isServer()`; pass `isServer` to override.

:::warning
- In the browser, `await queryClient.fetchQuery(orpc.x.streamedOptions())` resolves once the stream connects, unless a mounted query already follows that stream, in which case it waits for that stream. Pass `settleOnFirstData: false` to wait for the stream to end.
- Avoid `staleTime: 'static'`, which stops the stream from reopening on mount.
- When [streaming](https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr#streaming-with-server-components) pending queries, read them with `useSuspenseQuery`. A `useQuery` that mounts before the hydrated data arrives keeps that data until it goes stale.
- Do not mix suspense and non-suspense consumers of one streamed key across suspense boundaries. A mounted `useQuery` keeps the suspending fetch from settling, so the stream stays open.
:::
