Skip to content
Dashboard

How to use TanStack Query for server state, SSR, and streaming

Link to headingServer state needs a contract

Link to headingWhat TanStack Query manages

Link to headingCore primitives to learn first

export const threadKeys = {
all: ['threads'] as const,
list: (workspaceId: string) =>
[...threadKeys.all, { workspaceId }] as const,
detail: (threadId: string) =>
[...threadKeys.all, 'detail', threadId] as const,
messages: (threadId: string) =>
[...threadKeys.detail(threadId), 'messages'] as const,
liveMessages: (threadId: string) =>
[...threadKeys.detail(threadId), 'live'] as const,
}

import { useQuery } from '@tanstack/react-query'
export function useThread(threadId: string) {
return useQuery({
queryKey: threadKeys.detail(threadId),
queryFn: () => fetch(`/api/threads/${threadId}`).then((res) => res.json()),
staleTime: 30_000,
})
}

import { useMutation, useQueryClient } from '@tanstack/react-query'
export function useRenameThread(threadId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (title: string) => {
const res = await fetch(`/api/threads/${threadId}`, {
method: 'PATCH',
body: JSON.stringify({ title }),
})
if (!res.ok) throw new Error('Rename failed')
return res.json()
},
onMutate: async (title) => {
await queryClient.cancelQueries({
queryKey: threadKeys.detail(threadId),
})
const previous = queryClient.getQueryData(threadKeys.detail(threadId))
queryClient.setQueryData(threadKeys.detail(threadId), (thread: any) =>
thread ? { ...thread, title } : thread,
)
return { previous }
},
onError: (_error, _title, context) => {
queryClient.setQueryData(threadKeys.detail(threadId), context?.previous)
},
onSettled: () =>
queryClient.invalidateQueries({
queryKey: threadKeys.detail(threadId),
}),
})
}

Link to headingSSR begins on the server

import { QueryClient } from '@tanstack/react-query'
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
}

Link to headingNext.js App Router pattern

app/threads/[threadId]/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import { ThreadView } from './thread-view'
export default async function Page({
params,
}: {
params: Promise<{ threadId: string }>
}) {
const { threadId } = await params
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: threadKeys.detail(threadId),
queryFn: () => getThread(threadId),
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ThreadView threadId={threadId} />
</HydrationBoundary>
)
}

app/threads/[threadId]/thread-view.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
export function ThreadView({ threadId }: { threadId: string }) {
const { data: thread, isPending } = useQuery({
queryKey: threadKeys.detail(threadId),
queryFn: () => fetch(`/api/threads/${threadId}`).then((res) => res.json()),
})
if (isPending) return <p>Loading...</p>
if (!thread) return null
return <h1>{thread.title}</h1>
}

Link to headingNuxt, SvelteKit, Astro, and Remix

Link to headingNuxt uses a plugin boundary

plugins/vue-query.ts
import {
VueQueryPlugin,
QueryClient,
dehydrate,
hydrate,
type DehydratedState,
} from '@tanstack/vue-query'
export default defineNuxtPlugin((nuxt) => {
const queryClient = new QueryClient()
const state = useState<DehydratedState | null>('vue-query', () => null)
nuxt.vueApp.use(VueQueryPlugin, { queryClient })
if (import.meta.server) {
nuxt.hooks.hook('app:rendered', () => {
state.value = dehydrate(queryClient)
})
}
if (import.meta.client) {
hydrate(queryClient, state.value)
}
})

Link to headingSvelteKit prefetches in load

src/routes/+layout.ts
import { browser } from '$app/environment'
import { QueryClient } from '@tanstack/svelte-query'
export async function load() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
enabled: browser,
},
},
})
return { queryClient }
}

src/routes/+layout.svelte
<script lang="ts">
import { QueryClientProvider } from '@tanstack/svelte-query'
import type { LayoutData } from './$types'
export let data: LayoutData
</script>
<QueryClientProvider client={data.queryClient}>
<slot />
</QueryClientProvider>

src/routes/+page.ts
export async function load({ parent, fetch }) {
const { queryClient } = await parent()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: async () => (await fetch('/api/posts')).json(),
})
}

src/routes/+page.svelte
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query'
const posts = createQuery(() => ({
queryKey: ['posts'],
queryFn: async () => (await fetch('/api/posts')).json(),
}))
</script>
{#if posts.data}
{#each posts.data as post}
<article>{post.title}</article>
{/each}
{/if}

Link to headingAstro passes initial data to islands

---
import ThreadIsland from '../components/thread-island.tsx'
const thread = await fetch(`${Astro.url.origin}/api/thread`).then((res) =>
res.json(),
)
---
<ThreadIsland client:load initialThread={thread} />

import { useQuery } from '@tanstack/react-query'
export default function ThreadIsland({ initialThread }: any) {
const { data } = useQuery({
queryKey: threadKeys.detail(initialThread.id),
queryFn: () =>
fetch(`/api/threads/${initialThread.id}`).then((res) => res.json()),
initialData: initialThread,
})
return <h2>{data.title}</h2>
}

Link to headingRemix puts hydration in loaders

app/routes/threads.$threadId.tsx
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import {
dehydrate,
HydrationBoundary,
QueryClient,
useQuery,
} from '@tanstack/react-query'
export async function loader({ params }: any) {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: threadKeys.detail(params.threadId),
queryFn: () => getThread(params.threadId),
})
return json({ dehydratedState: dehydrate(queryClient) })
}
export default function Route() {
const { dehydratedState } = useLoaderData<typeof loader>()
return (
<HydrationBoundary state={dehydratedState}>
<Thread />
</HydrationBoundary>
)
}

Link to headingOptimistic updates need rollback paths

Link to headingInfinite lists need stable cursors

import { useInfiniteQuery } from '@tanstack/react-query'
export function useThreadMessages(threadId: string) {
return useInfiniteQuery({
queryKey: threadKeys.messages(threadId),
queryFn: ({ pageParam }) =>
fetch(`/api/threads/${threadId}/messages?cursor=${pageParam}`).then(
(res) => res.json(),
),
initialPageParam: 'latest',
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
}

Link to headingAgent threads are server state

'use client'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { useMutation, useQueryClient } from '@tanstack/react-query'
export function AgentThread({ threadId }: { threadId: string }) {
const queryClient = useQueryClient()
const chat = useChat({
transport: new DefaultChatTransport({
api: `/api/threads/${threadId}/chat`,
}),
onFinish: () => {
queryClient.invalidateQueries({
queryKey: threadKeys.detail(threadId),
})
},
onError: () => {
queryClient.invalidateQueries({
queryKey: threadKeys.messages(threadId),
})
},
})
const sendMessage = useMutation({
mutationFn: ({ id, text }: { id: string; text: string }) =>
chat.sendMessage({ text, messageId: id }),
onMutate: async ({ id, text }) => {
await queryClient.cancelQueries({
queryKey: threadKeys.liveMessages(threadId),
})
const previous = queryClient.getQueryData(
threadKeys.liveMessages(threadId),
)
queryClient.setQueryData(
threadKeys.liveMessages(threadId),
(
messages: Array<{
id: string
role: 'user'
content: string
status: 'pending'
}> = [],
) => [
...messages,
{ id, role: 'user', content: text, status: 'pending' },
],
)
return { previous }
},
onError: (_error, _draft, context) => {
queryClient.setQueryData(
threadKeys.liveMessages(threadId),
context?.previous,
)
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: threadKeys.messages(threadId),
})
},
})
return (
<form
onSubmit={(event) => {
event.preventDefault()
const form = event.currentTarget
const text = new FormData(form).get('text') as string
sendMessage.mutate({
id: crypto.randomUUID(),
text,
})
form.reset()
}}
>
{chat.messages.map((message) => (
<Message key={message.id} message={message} status={chat.status} />
))}
<input name="text" />
</form>
)
}

Link to headingStreaming tokens belong in cache

type ToolStatus = 'pending' | 'running' | 'completed' | 'errored'
type ThreadMessage = {
id: string
role: 'user' | 'assistant'
content: string
toolCalls?: Record<string, { name: string; status: ToolStatus }>
}
export function appendToken(
queryClient: QueryClient,
threadId: string,
messageId: string,
delta: string,
) {
queryClient.setQueryData(
threadKeys.liveMessages(threadId),
(messages: ThreadMessage[] = []) =>
messages.map((message) =>
message.id === messageId
? { ...message, content: message.content + delta }
: message,
),
)
}

export function setToolStatus(
queryClient: QueryClient,
threadId: string,
messageId: string,
toolCallId: string,
status: ToolStatus,
) {
queryClient.setQueryData(
threadKeys.liveMessages(threadId),
(messages: ThreadMessage[] = []) =>
messages.map((message) =>
message.id === messageId
? {
...message,
toolCalls: {
...message.toolCalls,
[toolCallId]: {
name: message.toolCalls?.[toolCallId]?.name ?? 'tool',
status,
},
},
}
: message,
),
)
}

lib/chat.ts
// lib/chat.ts
import { Chat } from 'chat'
import { createSlackAdapter } from '@chat-adapter/slack'
import { createRedisState } from '@chat-adapter/state-redis'
export const workspaceChat = new Chat({
userName: 'agent',
adapters: {
slack: createSlackAdapter(),
},
state: createRedisState(),
})

app/api/threads/[threadId]/chat/route.ts
// app/api/threads/[threadId]/chat/route.ts
import { convertToModelMessages, streamText } from 'ai'
import { createChatTools } from 'chat/ai'
import { workspaceChat } from '@/lib/chat'
export const maxDuration = 800
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: process.env.AI_MODEL!,
messages: await convertToModelMessages(messages),
tools: createChatTools({
chat: workspaceChat,
preset: ['reader', 'messenger'],
}),
})
return result.toUIMessageStreamResponse({
originalMessages: messages,
})
}

Link to headingFluid compute for long-lived, I/O-heavy AI requests

Link to headingTanStack Query FAQ

Link to headingDoes TanStack Query replace Redux or Zustand?

Link to headingShould every framework use the same query keys?

Link to headingShould server-rendered pages always hydrate TanStack Query?

Link to headingShould chat tokens live in TanStack Query?

Ready to deploy?