Skip to content
5 changes: 2 additions & 3 deletions apps/content/blog/nextjs-ssr-orpc-tanstack-query.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ That handoff needs a carefully configured `QueryClient`. Create `lib/query-clien

```ts lib/query-client.ts
import { RPCJsonSerializer } from '@orpc/client'
import { createHydrationOptions, createQueryKeyHashFn } from '@orpc/tanstack-query'
import { createHydrationOptions } from '@orpc/tanstack-query'
import { environmentManager, QueryClient } from '@tanstack/react-query'

let browserQueryClient: QueryClient | undefined
Expand All @@ -214,7 +214,6 @@ function createQueryClient(): QueryClient {
defaultOptions: {
queries: {
staleTime: 60 * 1000, // > 0 to prevent immediate refetching on mount
queryKeyHashFn: createQueryKeyHashFn(serializer),
},
...createHydrationOptions(serializer),
},
Expand All @@ -225,7 +224,7 @@ function createQueryClient(): QueryClient {
Each piece has one job:

- **`getQueryClient`** creates a fresh client for every server request, so one user's data can never leak into another user's HTML, while the browser reuses a singleton.
- **`createQueryKeyHashFn` and `createHydrationOptions`** teach query keys and the cache handoff about oRPC's extra types. Dehydration normally goes through plain JSON, which would turn our `Date` objects into strings. `RPCJsonSerializer` round-trips them intact. See [Custom Serializers](/docs/integrations/tanstack-query#custom-serializers) to extend it with your own types.
- **`createHydrationOptions`** teaches the cache handoff about oRPC's extra types. Dehydration normally goes through plain JSON, which would turn our `Date` objects into strings. `RPCJsonSerializer` round-trips them intact. See [Custom Serializers](/docs/integrations/tanstack-query#custom-serializers) to extend it with your own types.
- **`staleTime: 60 * 1000`** marks hydrated data as fresh, so the browser doesn't immediately refetch what the server just rendered.

## Wire Up the Providers
Expand Down
16 changes: 2 additions & 14 deletions apps/content/docs/integrations/pinia-colada.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -162,22 +162,10 @@ queryCache.setQueryData(orpc.planet.find.queryKey({ input: { id: 123 } }), (old)

### Key Serializer

Because Pinia Colada requires entry keys to be serializable, oRPC serializes inputs with the [RPC JSON Serializer](/docs/rpc/serializer#rpc-json-serializer) when building keys. Native types like `Date`, `URL`, and `BigInt` are supported by default, and keys keep track of each value's type, so a `Date` and its ISO string produce different keys. To support [custom types](/docs/rpc/serializer#custom-serializers), pass the same serializer your [link](/docs/rpc/link#custom-serializer) uses:
Pinia Colada requires serializable keys, so oRPC serializes inputs with the [RPC JSON Serializer](/docs/rpc/serializer#rpc-json-serializer) when building them. Inputs like `Date`, `URL`, and `BigInt` are supported, 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
import { RPCSerializer } from '@orpc/client'

const serializer = new RPCSerializer({
handlers: {
// ...custom handlers
},
})

const link = new RPCLink({ url, serializer })

const orpc = createPiniaColadaUtils(client, {
serializer,
})
const orpc = createPiniaColadaUtils(client, { serializer })
```

## Calling Procedure Clients
Expand Down
33 changes: 29 additions & 4 deletions apps/content/docs/integrations/tanstack-query.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ queryClient.setQueryData(orpc.planet.find.queryKey({ input: { id: 123 } }), (old
})
```

### 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.
Expand Down Expand Up @@ -258,7 +266,7 @@ const customQuery = useQuery(orpc.planet.find.queryOptions({
```

:::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.
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
Expand Down Expand Up @@ -504,23 +512,40 @@ const query = useInfiniteQuery(
)
```

## 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 hashes query keys and 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 `createQueryKeyHashFn` and `createHydrationOptions` to keep them intact.
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, createQueryKeyHashFn } from '@orpc/tanstack-query'
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
queryKeyHashFn: createQueryKeyHashFn(serializer),
},
...createHydrationOptions(serializer), // dehydrate.serializeData and hydrate.deserializeData
},
Expand Down
27 changes: 27 additions & 0 deletions apps/content/docs/openapi/serializer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,33 @@ const link = new OpenAPILink(contract, {

:::

## OpenAPI JSON Serializer

`OpenAPIJsonSerializer` is the JSON layer behind `OpenAPISerializer`. Use it directly wherever only JSON is accepted.

```ts twoslash
import { OpenAPIJsonSerializer } from '@orpc/openapi'

const serializer = new OpenAPIJsonSerializer({
handlers: {
// same handlers as OpenAPISerializer
},
})

const { json } = serializer.serializeJson({
name: 'John',
createdAt: new Date('2024-01-01'),
})
// json: { name: 'John', createdAt: '2024-01-01T00:00:00.000Z' }

const data = serializer.deserializeJson({ json })
// data: { name: 'John', createdAt: '2024-01-01T00:00:00.000Z' }
```

:::info[Reuse an OpenAPI Serializer]
`OpenAPISerializer` extends `OpenAPIJsonSerializer`, so an `OpenAPISerializer` instance also works wherever an `OpenAPIJsonSerializer` is accepted, such as [MCP outputs](/docs/integrations/mcp#output), [TanStack Query keys](/docs/integrations/tanstack-query#key-serializer), or [Pinia Colada keys](/docs/integrations/pinia-colada#key-serializer). Configure your handlers once and share the same instance.
:::

## Serialization Format

In most cases, serialized data is JSON-serializable.
Expand Down
2 changes: 1 addition & 1 deletion apps/content/docs/rpc/serializer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ const data = serializer.deserializeJson({ json, meta })
```

:::info[Reuse an RPC Serializer]
`RPCSerializer` extends `RPCJsonSerializer`, so an `RPCSerializer` instance also works wherever an `RPCJsonSerializer` is accepted, such as [Publisher adapters](/docs/helpers/publisher#adapters), [TanStack Query hydration](/docs/integrations/tanstack-query#custom-serializers), or [Pinia Colada keys](/docs/integrations/pinia-colada#key-serializer). Configure your handlers once and share the same instance.
`RPCSerializer` extends `RPCJsonSerializer`, so an `RPCSerializer` instance also works wherever an `RPCJsonSerializer` is accepted, such as [Publisher adapters](/docs/helpers/publisher#adapters), [TanStack Query keys](/docs/integrations/tanstack-query#key-serializer) and [hydration](/docs/integrations/tanstack-query#custom-serializers), or [Pinia Colada keys](/docs/integrations/pinia-colada#key-serializer). Configure your handlers once and share the same instance.
:::

## Serialization Format
Expand Down
6 changes: 6 additions & 0 deletions packages/openapi/src/openapi-json-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ export interface OpenAPIJsonSerializerOptions {
omitUndefinedProperties?: boolean | undefined
}

/**
* The JSON layer behind `OpenAPISerializer`: one-way serialization of types like `Date`, `BigInt`,
* and `Set` into JSON-friendly values, for places that only accept JSON.
*
* @see {@link https://orpc.dev/docs/openapi/serializer#openapi-json-serializer | OpenAPI Serializer - OpenAPI JSON Serializer}
*/
export class OpenAPIJsonSerializer {
private readonly inlineBuiltInHandlers: boolean
private readonly handlerEntries: OpenAPIJsonSerializerHandler[] | undefined
Expand Down
7 changes: 7 additions & 0 deletions packages/pinia-colada/src/key.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { RPCJsonSerializer } from '@orpc/client'
import { OpenAPIJsonSerializer } from '@orpc/openapi'
import { useQueryCache } from '@pinia/colada'
import { createPinia } from 'pinia'
import { generateOperationKey } from './key'
Expand Down Expand Up @@ -103,6 +104,12 @@ describe('generateOperationKey serializer', () => {
}])
})

it('accepts an OpenAPI serializer, whose keys carry no type info', () => {
expect(generateOperationKey(['path'], { serializer: new OpenAPIJsonSerializer(), input: { a: new Date(0) } })).toEqual([['path'], {
input: { a: '1970-01-01T00:00:00.000Z' },
}])
})

it('skips the serializer when input is undefined', () => {
const serializeJson = vi.fn()

Expand Down
5 changes: 5 additions & 0 deletions packages/pinia-colada/src/router-utils.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ProcedureUtils } from './procedure-utils'
import type { RouterUtils } from './router-utils'
import type { SharedUtils } from './shared-utils'
import { RPCSerializer } from '@orpc/client'
import { OpenAPISerializer } from '@orpc/openapi'
import { os } from '@orpc/server'
import z from 'zod'
import { createRouterUtils } from './router-utils'
Expand Down Expand Up @@ -56,6 +57,10 @@ it('RouterUtils', () => {
>()
})

it('createRouterUtils accepts an OpenAPI serializer', () => {
createRouterUtils({} as RouterClient<typeof router>, { serializer: new OpenAPISerializer() })
})

it('createRouterUtils', () => {
const utils = createRouterUtils({} as RouterClient<typeof router, { batch?: boolean }>, {
prefix: '__prefix__',
Expand Down
1 change: 1 addition & 0 deletions packages/tanstack-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { createRouterUtils as createTanstackQueryUtils } from './router-utils'
export * from './shared-utils'
export * from './ssr'
export * from './stream-query'
export * from './structural-sharing'
export * from './types'
export {
OPERATION_CONTEXT_SYMBOL as TANSTACK_QUERY_OPERATION_CONTEXT_SYMBOL,
Expand Down
Loading
Loading