From f396a2f47e87b589010396c1a9dcc1109e1851ba Mon Sep 17 00:00:00 2001
From: TkDodo <office@dorfmeister.cc>
Date: Fri, 24 Apr 2026 09:43:50 +0200
Subject: [PATCH 1/4] feat(query): add support for custom dehydrate and hydrate
 options in SSR integration

---
 docs/router/integrations/query.md             |  55 +++++
 packages/router-ssr-query-core/src/index.ts   |  50 +++--
 .../router-ssr-query-core/tests/index.test.ts | 208 ++++++++++++++++++
 3 files changed, 298 insertions(+), 15 deletions(-)
 create mode 100644 packages/router-ssr-query-core/tests/index.test.ts

diff --git a/docs/router/integrations/query.md b/docs/router/integrations/query.md
index 22fbb927c8..5cc1f48a07 100644
--- a/docs/router/integrations/query.md
+++ b/docs/router/integrations/query.md
@@ -64,6 +64,61 @@ export function getRouter() {
 
 By default, the integration wraps your router with a `QueryClientProvider`. If you already provide your own provider, pass `wrapQueryClient: false` and keep your custom wrapper.
 
+You can also pass `dehydrateOptions` and `hydrateOptions` to customize how TanStack Query serializes SSR payloads and restores them on the client.
+
+```tsx
+setupRouterSsrQueryIntegration({
+  router,
+  queryClient,
+  dehydrateOptions: {
+    shouldDehydrateQuery: (query) => query.meta?.ssr !== false,
+  },
+  hydrateOptions: {
+    defaultOptions: {
+      queries: {
+        gcTime: 60_000,
+      },
+    },
+  },
+})
+```
+
+Common uses for these options:
+
+- Set `hydrateOptions.defaultOptions.queries.gcTime` to control how long hydrated SSR queries stay in the client cache before garbage collection.
+- Set `dehydrateOptions.shouldDehydrateQuery` to exclude queries you do not want serialized into the HTML payload.
+- Set `dehydrateOptions.serializeData` and `hydrateOptions.defaultOptions.deserializeData` when your SSR payload needs custom serialization.
+
+```tsx title="src/router.tsx"
+import { QueryClient } from '@tanstack/react-query'
+import { createRouter } from '@tanstack/react-router'
+import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
+import { routeTree } from './routeTree.gen'
+
+export function getRouter() {
+  const queryClient = new QueryClient()
+
+  const router = createRouter({
+    routeTree,
+    context: { queryClient },
+  })
+
+  setupRouterSsrQueryIntegration({
+    router,
+    queryClient,
+    hydrateOptions: {
+      defaultOptions: {
+        queries: {
+          gcTime: 5 * 60 * 1000,
+        },
+      },
+    },
+  })
+
+  return router
+}
+```
+
 ## SSR behavior and streaming
 
 - During server render, the integration dehydrates initial queries and streams any subsequent queries that resolve while rendering.
diff --git a/packages/router-ssr-query-core/src/index.ts b/packages/router-ssr-query-core/src/index.ts
index 4be8657376..4078d19687 100644
--- a/packages/router-ssr-query-core/src/index.ts
+++ b/packages/router-ssr-query-core/src/index.ts
@@ -6,6 +6,8 @@ import { isRedirect } from '@tanstack/router-core'
 import { isServer } from '@tanstack/router-core/isServer'
 import type { AnyRouter } from '@tanstack/router-core'
 import type {
+  DehydrateOptions,
+  HydrateOptions,
   QueryClient,
   DehydratedState as QueryDehydratedState,
 } from '@tanstack/query-core'
@@ -13,6 +15,8 @@ import type {
 export type RouterSsrQueryOptions<TRouter extends AnyRouter> = {
   router: TRouter
   queryClient: QueryClient
+  dehydrateOptions?: DehydrateOptions
+  hydrateOptions?: HydrateOptions
 
   /**
    * If `true`, the QueryClient will handle errors thrown by `redirect()` inside of mutations and queries.
@@ -31,6 +35,8 @@ type DehydratedRouterQueryState = {
 export function setupCoreRouterSsrQueryIntegration<TRouter extends AnyRouter>({
   router,
   queryClient,
+  dehydrateOptions,
+  hydrateOptions,
   handleRedirects = true,
 }: RouterSsrQueryOptions<TRouter>) {
   const ogHydrate = router.options.hydrate
@@ -55,7 +61,10 @@ export function setupCoreRouterSsrQueryIntegration<TRouter extends AnyRouter>({
           queryStream: queryStream.stream,
         }
 
-        const dehydratedQueryClient = queryDehydrate(queryClient)
+        const dehydratedQueryClient = queryDehydrate(
+          queryClient,
+          dehydrateOptions,
+        )
         if (dehydratedQueryClient.queries.length > 0) {
           dehydratedQueryClient.queries.forEach((query) => {
             sentQueries.add(query.queryHash)
@@ -95,19 +104,26 @@ export function setupCoreRouterSsrQueryIntegration<TRouter extends AnyRouter>({
         )
         return
       }
-      sentQueries.add(event.query.queryHash)
-      queryStream.enqueue(
-        queryDehydrate(queryClient, {
-          shouldDehydrateQuery: (query) => {
-            if (query.queryHash === event.query.queryHash) {
-              return (
-                ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ?? true
-              )
-            }
+      const dehydratedQuery = queryDehydrate(queryClient, {
+        ...dehydrateOptions,
+        shouldDehydrateQuery: (query) => {
+          if (query.queryHash !== event.query.queryHash) {
             return false
-          },
-        }),
-      )
+          }
+
+          return (
+            (ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ?? true) &&
+            (dehydrateOptions?.shouldDehydrateQuery?.(query) ?? true)
+          )
+        },
+      })
+
+      if (dehydratedQuery.queries.length === 0) {
+        return
+      }
+
+      sentQueries.add(event.query.queryHash)
+      queryStream.enqueue(dehydratedQuery)
     })
     // on the client
   } else {
@@ -115,7 +131,11 @@ export function setupCoreRouterSsrQueryIntegration<TRouter extends AnyRouter>({
       await ogHydrate?.(dehydrated)
       // hydrate the query client with the dehydrated data (if it was dehydrated on the server)
       if (dehydrated.dehydratedQueryClient) {
-        queryHydrate(queryClient, dehydrated.dehydratedQueryClient)
+        queryHydrate(
+          queryClient,
+          dehydrated.dehydratedQueryClient,
+          hydrateOptions,
+        )
       }
 
       // read the query stream and hydrate the queries as they come in
@@ -123,7 +143,7 @@ export function setupCoreRouterSsrQueryIntegration<TRouter extends AnyRouter>({
       reader
         .read()
         .then(async function handle({ done, value }) {
-          queryHydrate(queryClient, value)
+          queryHydrate(queryClient, value, hydrateOptions)
           if (done) {
             return
           }
diff --git a/packages/router-ssr-query-core/tests/index.test.ts b/packages/router-ssr-query-core/tests/index.test.ts
new file mode 100644
index 0000000000..93be3590ab
--- /dev/null
+++ b/packages/router-ssr-query-core/tests/index.test.ts
@@ -0,0 +1,208 @@
+import { QueryClient } from '@tanstack/query-core'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { setupCoreRouterSsrQueryIntegration } from '../src'
+
+type TestRouter = {
+  isServer: boolean
+  options: {
+    dehydrate?: () => unknown | Promise<unknown>
+    hydrate?: (dehydrated: any) => unknown | Promise<unknown>
+  }
+  serverSsr?: {
+    isDehydrated: () => boolean
+    onRenderFinished: (listener: () => void) => void
+  }
+}
+
+function createServerRouter(): {
+  router: TestRouter
+  finishRender: () => void
+  setDehydrated: (value: boolean) => void
+} {
+  const renderFinishedListeners = new Array<() => void>()
+  let dehydrated = false
+
+  return {
+    router: {
+      isServer: true,
+      options: {},
+      serverSsr: {
+        isDehydrated: () => dehydrated,
+        onRenderFinished: (listener) => {
+          renderFinishedListeners.push(listener)
+        },
+      },
+    },
+    finishRender: () => {
+      renderFinishedListeners.splice(0).forEach((listener) => listener())
+    },
+    setDehydrated: (value) => {
+      dehydrated = value
+    },
+  }
+}
+
+async function readStream<T>(stream: ReadableStream<T>): Promise<Array<T>> {
+  const reader = stream.getReader()
+  const chunks = new Array<T>()
+
+  while (true) {
+    const result = await reader.read()
+
+    if (result.done) {
+      return chunks
+    }
+
+    chunks.push(result.value)
+  }
+}
+
+function createDeferred<T>() {
+  let resolve!: (value: T) => void
+  const promise = new Promise<T>((res) => {
+    resolve = res
+  })
+
+  return {
+    promise,
+    resolve,
+  }
+}
+
+function createDehydratedQueryState(data: string) {
+  return {
+    data,
+    dataUpdateCount: 1,
+    dataUpdatedAt: 1,
+    error: null,
+    errorUpdateCount: 0,
+    errorUpdatedAt: 0,
+    fetchFailureCount: 0,
+    fetchFailureReason: null,
+    fetchMeta: null,
+    fetchStatus: 'idle' as const,
+    isInvalidated: false,
+    status: 'success' as const,
+  }
+}
+
+afterEach(() => {
+  vi.clearAllMocks()
+})
+
+describe('setupCoreRouterSsrQueryIntegration', () => {
+  it('uses custom dehydrate options for the initial payload and streamed queries', async () => {
+    const queryClient = new QueryClient()
+    const { router, finishRender, setDehydrated } = createServerRouter()
+
+    setupCoreRouterSsrQueryIntegration({
+      router: router as any,
+      queryClient,
+      dehydrateOptions: {
+        serializeData: (data) => `${data}-serialized`,
+        shouldDehydrateQuery: (query) => query.queryKey[0] !== 'skip',
+      },
+    })
+
+    queryClient.setQueryData(['include'], 'initial')
+    queryClient.setQueryData(['skip'], 'ignored')
+
+    const dehydrated = (await router.options.dehydrate?.()) as {
+      dehydratedQueryClient?: {
+        queries: Array<{ queryKey: Array<unknown>; state: { data: unknown } }>
+      }
+      queryStream: ReadableStream<{
+        queries: Array<{ queryKey: Array<unknown>; state: { data: unknown } }>
+      }>
+    }
+
+    expect(dehydrated.dehydratedQueryClient?.queries).toHaveLength(1)
+    expect(dehydrated.dehydratedQueryClient?.queries[0]?.queryKey).toEqual([
+      'include',
+    ])
+    expect(dehydrated.dehydratedQueryClient?.queries[0]?.state.data).toBe(
+      'initial-serialized',
+    )
+
+    const streamedQueriesPromise = readStream(dehydrated.queryStream)
+    const includedDeferred = createDeferred<string>()
+    const skippedDeferred = createDeferred<string>()
+
+    setDehydrated(true)
+    const includedPromise = queryClient.fetchQuery({
+      queryKey: ['streamed'],
+      queryFn: () => includedDeferred.promise,
+    })
+    const skippedPromise = queryClient.fetchQuery({
+      queryKey: ['skip'],
+      queryFn: () => skippedDeferred.promise,
+    })
+
+    await Promise.resolve()
+    includedDeferred.resolve('next')
+    skippedDeferred.resolve('still-ignored')
+    await Promise.all([includedPromise, skippedPromise])
+    finishRender()
+
+    const streamedQueries = await streamedQueriesPromise
+
+    expect(streamedQueries).toHaveLength(1)
+    expect(streamedQueries[0]?.queries).toHaveLength(1)
+    expect(streamedQueries[0]?.queries[0]?.queryKey).toEqual(['streamed'])
+    expect(streamedQueries[0]?.queries[0]?.state.data).toBe('next-serialized')
+  })
+
+  it('uses custom hydrate options for the initial payload and streamed queries', async () => {
+    const queryClient = new QueryClient()
+    const router: TestRouter = {
+      isServer: false,
+      options: {},
+    }
+
+    setupCoreRouterSsrQueryIntegration({
+      router: router as any,
+      queryClient,
+      hydrateOptions: {
+        defaultOptions: {
+          deserializeData: (data) => `${String(data)}-hydrated`,
+        },
+      },
+    })
+
+    const stream = new ReadableStream({
+      start(controller) {
+        controller.enqueue({
+          mutations: [],
+          queries: [
+            {
+              queryHash: '["streamed"]',
+              queryKey: ['streamed'],
+              state: createDehydratedQueryState('stream'),
+            },
+          ],
+        })
+        controller.close()
+      },
+    })
+
+    await router.options.hydrate?.({
+      dehydratedQueryClient: {
+        mutations: [],
+        queries: [
+          {
+            queryHash: '["initial"]',
+            queryKey: ['initial'],
+            state: createDehydratedQueryState('initial'),
+          },
+        ],
+      },
+      queryStream: stream,
+    })
+
+    await Promise.resolve()
+    await Promise.resolve()
+
+    expect(queryClient.getQueryData(['initial'])).toBe('initial-hydrated')
+    expect(queryClient.getQueryData(['streamed'])).toBe('stream-hydrated')
+  })
+})

From dac1547ce274fb2ef3cdd596b7ad8a7fcdb0ce68 Mon Sep 17 00:00:00 2001
From: TkDodo <office@dorfmeister.cc>
Date: Fri, 24 Apr 2026 09:57:47 +0200
Subject: [PATCH 2/4] format

---
 packages/router-ssr-query-core/src/index.ts | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/packages/router-ssr-query-core/src/index.ts b/packages/router-ssr-query-core/src/index.ts
index 4078d19687..188dbf13a8 100644
--- a/packages/router-ssr-query-core/src/index.ts
+++ b/packages/router-ssr-query-core/src/index.ts
@@ -112,7 +112,8 @@ export function setupCoreRouterSsrQueryIntegration<TRouter extends AnyRouter>({
           }
 
           return (
-            (ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ?? true) &&
+            (ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ??
+              true) &&
             (dehydrateOptions?.shouldDehydrateQuery?.(query) ?? true)
           )
         },

From f0dd533d6338915fda4cc851aec8e4b9b69dfe74 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 24 Apr 2026 20:42:45 +0000
Subject: [PATCH 3/4] chore: add changeset for query SSR dehydrate/hydrate
 options

Agent-Logs-Url: https://github.com/TanStack/router/sessions/480a8e1b-6c6b-4f18-95df-6a522aebd69e

Co-authored-by: schiller-manuel <6340397+schiller-manuel@users.noreply.github.com>
---
 .changeset/query-ssr-dehydrate-hydrate-options.md | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 .changeset/query-ssr-dehydrate-hydrate-options.md

diff --git a/.changeset/query-ssr-dehydrate-hydrate-options.md b/.changeset/query-ssr-dehydrate-hydrate-options.md
new file mode 100644
index 0000000000..6e38a54c06
--- /dev/null
+++ b/.changeset/query-ssr-dehydrate-hydrate-options.md
@@ -0,0 +1,5 @@
+---
+"@tanstack/router-ssr-query-core": minor
+---
+
+Add support for custom `dehydrateOptions` and `hydrateOptions` in the SSR query integration

From 6fecadf912fb501f3184109960c373dfeef46249 Mon Sep 17 00:00:00 2001
From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com>
Date: Fri, 24 Apr 2026 21:05:09 +0000
Subject: [PATCH 4/4] ci: apply automated fixes

---
 .changeset/query-ssr-dehydrate-hydrate-options.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.changeset/query-ssr-dehydrate-hydrate-options.md b/.changeset/query-ssr-dehydrate-hydrate-options.md
index 6e38a54c06..398d4c9916 100644
--- a/.changeset/query-ssr-dehydrate-hydrate-options.md
+++ b/.changeset/query-ssr-dehydrate-hydrate-options.md
@@ -1,5 +1,5 @@
 ---
-"@tanstack/router-ssr-query-core": minor
+'@tanstack/router-ssr-query-core': minor
 ---
 
 Add support for custom `dehydrateOptions` and `hydrateOptions` in the SSR query integration
