diff --git a/babel.config.cjs b/babel.config.cjs
index 85c9a6c2bf..62a4cbae45 100644
--- a/babel.config.cjs
+++ b/babel.config.cjs
@@ -38,6 +38,7 @@ module.exports = {
'./packages/react-query/**',
'./packages/react-query-devtools/**',
'./packages/react-query-persist-client/**',
+ './packages/react-query-next-experimental/**',
],
presets: ['@babel/react'],
},
diff --git a/docs/config.json b/docs/config.json
index 29e2ac321b..dbe2a967af 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -278,6 +278,10 @@
"label": "Next.js",
"to": "react/examples/react/nextjs"
},
+ {
+ "label": "Next.js app with streaming",
+ "to": "react/examples/react/nextjs-suspense-streaming"
+ },
{
"label": "React Native",
"to": "react/examples/react/react-native"
diff --git a/examples/react/nextjs-suspense-streaming/next.config.js b/examples/react/nextjs-suspense-streaming/next.config.js
new file mode 100644
index 0000000000..9b0c4c0d96
--- /dev/null
+++ b/examples/react/nextjs-suspense-streaming/next.config.js
@@ -0,0 +1,17 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ eslint: {
+ ignoreDuringBuilds: true,
+ },
+ experimental: {
+ appDir: true,
+ serverActions: true,
+ },
+ webpack: (config) => {
+ if (config.name === 'server') config.optimization.concatenateModules = false
+
+ return config
+ },
+}
+
+module.exports = nextConfig
diff --git a/examples/react/nextjs-suspense-streaming/package.json b/examples/react/nextjs-suspense-streaming/package.json
new file mode 100644
index 0000000000..187491930f
--- /dev/null
+++ b/examples/react/nextjs-suspense-streaming/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@tanstack/query-example-nextjs-suspense-streaming",
+ "private": true,
+ "license": "MIT",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^v5.0.0-alpha.68",
+ "@tanstack/react-query-devtools": "^v5.0.0-alpha.68",
+ "next": "^13.4.4",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "superjson": "^1.12.3"
+ },
+ "devDependencies": {
+ "@types/node": "20.2.5",
+ "@types/react": "18.2.8",
+ "typescript": "5.1.3"
+ }
+}
diff --git a/examples/react/nextjs-suspense-streaming/src/app/api/wait/route.ts b/examples/react/nextjs-suspense-streaming/src/app/api/wait/route.ts
new file mode 100644
index 0000000000..7fd4825e38
--- /dev/null
+++ b/examples/react/nextjs-suspense-streaming/src/app/api/wait/route.ts
@@ -0,0 +1,10 @@
+import { NextResponse } from 'next/server'
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url)
+ const wait = Number(searchParams.get('wait'))
+
+ await new Promise((resolve) => setTimeout(resolve, wait))
+
+ return NextResponse.json(`waited ${wait}ms`)
+}
diff --git a/examples/react/nextjs-suspense-streaming/src/app/layout.tsx b/examples/react/nextjs-suspense-streaming/src/app/layout.tsx
new file mode 100644
index 0000000000..550174f7d9
--- /dev/null
+++ b/examples/react/nextjs-suspense-streaming/src/app/layout.tsx
@@ -0,0 +1,20 @@
+import { Providers } from './providers'
+
+export const metadata = {
+ title: 'Next.js',
+ description: 'Generated by Next.js',
+}
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/examples/react/nextjs-suspense-streaming/src/app/page.tsx b/examples/react/nextjs-suspense-streaming/src/app/page.tsx
new file mode 100644
index 0000000000..dfe62c1c42
--- /dev/null
+++ b/examples/react/nextjs-suspense-streaming/src/app/page.tsx
@@ -0,0 +1,89 @@
+'use client'
+import { useQuery } from '@tanstack/react-query'
+import { Suspense } from 'react'
+
+// export const runtime = "edge"; // 'nodejs' (default) | 'edge'
+
+function getBaseURL() {
+ if (typeof window !== 'undefined') {
+ return ''
+ }
+ if (process.env.VERCEL_URL) {
+ return `https://${process.env.VERCEL_URL}`
+ }
+ return 'http://localhost:3000'
+}
+const baseUrl = getBaseURL()
+function useWaitQuery(props: { wait: number }) {
+ const query = useQuery({
+ queryKey: ['wait', props.wait],
+ queryFn: async () => {
+ const path = `/api/wait?wait=${props.wait}`
+ const url = baseUrl + path
+
+ console.log('fetching', url)
+ const res: string = await (
+ await fetch(url, {
+ cache: 'no-store',
+ })
+ ).json()
+ return res
+ },
+ suspense: true,
+ })
+
+ return [query.data as string, query] as const
+}
+
+function MyComponent(props: { wait: number }) {
+ const [data] = useWaitQuery(props)
+
+ return result: {data}
+}
+
+export default function MyPage() {
+ return (
+ <>
+ waiting 100....}>
+
+
+ waiting 200....}>
+
+
+ waiting 300....}>
+
+
+ waiting 400....}>
+
+
+ waiting 500....}>
+
+
+ waiting 600....}>
+
+
+ waiting 700....}>
+
+
+
+
+ >
+ )
+}
diff --git a/examples/react/nextjs-suspense-streaming/src/app/providers.tsx b/examples/react/nextjs-suspense-streaming/src/app/providers.tsx
new file mode 100644
index 0000000000..407367b3c9
--- /dev/null
+++ b/examples/react/nextjs-suspense-streaming/src/app/providers.tsx
@@ -0,0 +1,29 @@
+// app/providers.jsx
+'use client'
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
+import React from 'react'
+import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
+
+export function Providers(props: { children: React.ReactNode }) {
+ const [queryClient] = React.useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 5 * 1000,
+ },
+ },
+ }),
+ )
+
+ return (
+
+
+ {props.children}
+
+
+
+ )
+}
diff --git a/examples/react/nextjs-suspense-streaming/tsconfig.json b/examples/react/nextjs-suspense-streaming/tsconfig.json
new file mode 100644
index 0000000000..6ef5cd577f
--- /dev/null
+++ b/examples/react/nextjs-suspense-streaming/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/react-query-devtools/vitest.config.ts b/packages/react-query-devtools/vitest.config.ts
index 70eec41e33..f61709f2fc 100644
--- a/packages/react-query-devtools/vitest.config.ts
+++ b/packages/react-query-devtools/vitest.config.ts
@@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
- name: 'react-query-persist-client',
+ name: 'react-query-devtools',
dir: './src',
watch: false,
setupFiles: ['test-setup.ts'],
diff --git a/packages/react-query-next-experimental/.eslintrc.cjs b/packages/react-query-next-experimental/.eslintrc.cjs
new file mode 100644
index 0000000000..a9fa6e620d
--- /dev/null
+++ b/packages/react-query-next-experimental/.eslintrc.cjs
@@ -0,0 +1,16 @@
+// @ts-check
+
+/** @type {import('eslint').Linter.Config} */
+const config = {
+ extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended'],
+ parserOptions: {
+ tsconfigRootDir: __dirname,
+ project: './tsconfig.eslint.json',
+ },
+ rules: {
+ 'react/jsx-key': ['error', { checkFragmentShorthand: true }],
+ 'react-hooks/exhaustive-deps': 'error',
+ },
+}
+
+module.exports = config
diff --git a/packages/react-query-next-experimental/package.json b/packages/react-query-next-experimental/package.json
new file mode 100644
index 0000000000..a7d357eafc
--- /dev/null
+++ b/packages/react-query-next-experimental/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "@tanstack/react-query-next-experimental",
+ "version": "5.0.0-alpha.67",
+ "description": "Hydration utils for React Query in the NextJs app directory",
+ "author": "tannerlinsley",
+ "license": "MIT",
+ "repository": "tanstack/query",
+ "homepage": "https://tanstack.com/query",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "type": "module",
+ "types": "build/lib/index.d.ts",
+ "main": "build/lib/index.legacy.cjs",
+ "module": "build/lib/index.legacy.js",
+ "exports": {
+ ".": {
+ "types": "./build/lib/index.d.ts",
+ "import": "./build/lib/index.js",
+ "require": "./build/lib/index.cjs",
+ "default": "./build/lib/index.cjs"
+ },
+ "./package.json": "./package.json"
+ },
+ "sideEffects": false,
+ "files": [
+ "build/lib/*",
+ "src"
+ ],
+ "scripts": {
+ "clean": "rimraf ./build && rimraf ./coverage",
+ "test:eslint": "eslint --ext .ts,.tsx ./src",
+ "test:types": "tsc --noEmit",
+ "test:lib": "vitest run --coverage --passWithNoTests",
+ "test:lib:dev": "pnpm run test:lib --watch",
+ "test:build": "publint --strict",
+ "build": "pnpm build:rollup && pnpm build:types",
+ "build:rollup": "rollup --config rollup.config.js",
+ "build:types": "tsc --emitDeclarationOnly"
+ },
+ "devDependencies": {
+ "@tanstack/react-query": "workspace:*",
+ "@types/react": "^18.2.4",
+ "@types/react-dom": "^18.2.4",
+ "next": "^13.4.6",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-error-boundary": "^3.1.4"
+ },
+ "peerDependencies": {
+ "@tanstack/react-query": "workspace:*",
+ "next": "^13.0.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+}
diff --git a/packages/react-query-next-experimental/rollup.config.js b/packages/react-query-next-experimental/rollup.config.js
new file mode 100644
index 0000000000..149a2f25d5
--- /dev/null
+++ b/packages/react-query-next-experimental/rollup.config.js
@@ -0,0 +1,12 @@
+// @ts-check
+
+import { defineConfig } from 'rollup'
+import { buildConfigs } from '../../scripts/getRollupConfig.js'
+
+export default defineConfig(
+ buildConfigs({
+ name: 'react-query-next-experimental',
+ outputFile: 'index',
+ entryFile: './src/index.ts',
+ }),
+)
diff --git a/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx b/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx
new file mode 100644
index 0000000000..7b8699049d
--- /dev/null
+++ b/packages/react-query-next-experimental/src/HydrationStreamProvider.tsx
@@ -0,0 +1,184 @@
+'use client'
+
+import { useServerInsertedHTML } from 'next/navigation'
+import * as React from 'react'
+
+const serializedSymbol = Symbol('serialized')
+
+interface DataTransformer {
+ serialize(object: any): any
+ deserialize(object: any): any
+}
+
+type Serialized = unknown & {
+ [serializedSymbol]: TData
+}
+
+interface TypedDataTransformer {
+ serialize: (obj: TData) => Serialized
+ deserialize: (obj: Serialized) => TData
+}
+
+interface HydrationStreamContext {
+ id: string
+ stream: {
+ /**
+ * **Server method**
+ * Push a new entry to the stream
+ * Will be ignored on the client
+ */
+ push: (...shape: TShape[]) => void
+ }
+}
+
+export interface HydrationStreamProviderProps {
+ children: React.ReactNode
+ /**
+ * Optional transformer to serialize/deserialize the data
+ * Example devalue, superjson et al
+ */
+ transformer?: DataTransformer
+ /**
+ * **Client method**
+ * Called in the browser when new entries are received
+ */
+ onEntries: (entries: TShape[]) => void
+ /**
+ * **Server method**
+ * onFlush is called on the server when the cache is flushed
+ */
+ onFlush?: () => TShape[]
+}
+
+export function createHydrationStreamProvider() {
+ const context = React.createContext>(
+ null as any,
+ )
+ /**
+
+ * 1. (Happens on server): `useServerInsertedHTML()` is called **on the server** whenever a `Suspense`-boundary completes
+ * - This means that we might have some new entries in the cache that needs to be flushed
+ * - We pass these to the client by inserting a `