diff --git a/examples/react/nextjs-app-optimistic-updates/.gitignore b/examples/react/nextjs-app-optimistic-updates/.gitignore
new file mode 100644
index 0000000000..b988ee9758
--- /dev/null
+++ b/examples/react/nextjs-app-optimistic-updates/.gitignore
@@ -0,0 +1,34 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
diff --git a/examples/react/nextjs-app-optimistic-updates/README.md b/examples/react/nextjs-app-optimistic-updates/README.md
new file mode 100644
index 0000000000..89b24ce9da
--- /dev/null
+++ b/examples/react/nextjs-app-optimistic-updates/README.md
@@ -0,0 +1,58 @@
+# TanStack Query — Next.js App Router Optimistic Updates
+
+This example demonstrates **optimistic updates** with TanStack Query v5 in a Next.js 14 App Router project.
+
+## What it shows
+
+A todo list where items appear in the UI immediately after submission — before the server confirms. The server randomly fails ~30% of the time so you can observe automatic rollback behaviour.
+
+Two approaches are shown side by side via a tab toggle:
+
+### Approach 1 — Via UI Variables (simpler)
+
+Render the pending item directly from `mutation.variables`. No cache touching required. On error, the pending item simply disappears and an error message is shown.
+
+```ts
+const mutation = useMutation({ mutationFn: addTodo, onSettled: invalidate })
+
+// In JSX:
+{mutation.isPending &&
{mutation.variables}
}
+```
+
+**Best when:** the mutation input maps 1-to-1 to what you'd show while pending.
+
+### Approach 2 — Via Cache Manipulation (`onMutate` + rollback)
+
+`onMutate` cancels in-flight refetches, snapshots the current cache, and writes an optimistic item directly into the cache. `onError` restores the snapshot.
+
+```ts
+const mutation = useMutation({
+ mutationFn: addTodo,
+ onMutate: async (text) => {
+ await queryClient.cancelQueries({ queryKey: ['todos'] })
+ const previousTodos = queryClient.getQueryData(['todos'])
+ queryClient.setQueryData(['todos'], (old = []) => [...old, optimistic])
+ return { previousTodos }
+ },
+ onError: (_err, _vars, context) => {
+ queryClient.setQueryData(['todos'], context?.previousTodos)
+ },
+ onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
+})
+```
+
+**Best when:** you need fine-grained control or the optimistic shape differs from `mutation.variables`.
+
+## Running the example
+
+```bash
+npm install
+npm run dev
+```
+
+Open [http://localhost:3000](http://localhost:3000).
+
+## Learn more
+
+- [TanStack Query — Optimistic Updates](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates)
+- [Next.js App Router](https://nextjs.org/docs/app)
diff --git a/examples/react/nextjs-app-optimistic-updates/app/api/todos/data.ts b/examples/react/nextjs-app-optimistic-updates/app/api/todos/data.ts
new file mode 100644
index 0000000000..32acd79356
--- /dev/null
+++ b/examples/react/nextjs-app-optimistic-updates/app/api/todos/data.ts
@@ -0,0 +1,16 @@
+export interface Todo {
+ id: string
+ text: string
+ createdAt: number
+}
+
+export const todos: Array = [
+ { id: crypto.randomUUID(), text: 'Buy groceries', createdAt: Date.now() - 3000 },
+ { id: crypto.randomUUID(), text: 'Walk the dog', createdAt: Date.now() - 2000 },
+ { id: crypto.randomUUID(), text: 'Read a book', createdAt: Date.now() - 1000 },
+]
+
+export async function getTodos(): Promise> {
+ await new Promise((resolve) => setTimeout(resolve, 200))
+ return todos
+}
diff --git a/examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts b/examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts
new file mode 100644
index 0000000000..8a91e4e720
--- /dev/null
+++ b/examples/react/nextjs-app-optimistic-updates/app/api/todos/route.ts
@@ -0,0 +1,46 @@
+import { NextResponse } from 'next/server'
+import { todos, getTodos, type Todo } from './data'
+
+export async function GET() {
+ return NextResponse.json(await getTodos())
+}
+
+export async function POST(request: Request) {
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return NextResponse.json({ error: 'text is required' }, { status: 400 })
+ }
+
+ const text =
+ body !== null &&
+ typeof body === 'object' &&
+ 'text' in body &&
+ typeof (body as { text: unknown }).text === 'string'
+ ? ((body as { text: string }).text.trim())
+ : ''
+
+ if (!text) {
+ return NextResponse.json({ error: 'text is required' }, { status: 400 })
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 500))
+
+ if (Math.random() < 0.3) {
+ return NextResponse.json(
+ { error: 'Server error — please try again' },
+ { status: 500 },
+ )
+ }
+
+ const newTodo: Todo = {
+ id: crypto.randomUUID(),
+ text,
+ createdAt: Date.now(),
+ }
+
+ todos.push(newTodo)
+
+ return NextResponse.json(newTodo, { status: 201 })
+}
diff --git a/examples/react/nextjs-app-optimistic-updates/app/get-query-client.ts b/examples/react/nextjs-app-optimistic-updates/app/get-query-client.ts
new file mode 100644
index 0000000000..1666beba77
--- /dev/null
+++ b/examples/react/nextjs-app-optimistic-updates/app/get-query-client.ts
@@ -0,0 +1,31 @@
+import {
+ QueryClient,
+ defaultShouldDehydrateQuery,
+ environmentManager,
+} from '@tanstack/react-query'
+
+function makeQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 60 * 1000,
+ },
+ dehydrate: {
+ shouldDehydrateQuery: (query) =>
+ defaultShouldDehydrateQuery(query) ||
+ query.state.status === 'pending',
+ },
+ },
+ })
+}
+
+let browserQueryClient: QueryClient | undefined = undefined
+
+export function getQueryClient() {
+ if (environmentManager.isServer()) {
+ return makeQueryClient()
+ } else {
+ if (!browserQueryClient) browserQueryClient = makeQueryClient()
+ return browserQueryClient
+ }
+}
diff --git a/examples/react/nextjs-app-optimistic-updates/app/layout.tsx b/examples/react/nextjs-app-optimistic-updates/app/layout.tsx
new file mode 100644
index 0000000000..055162043c
--- /dev/null
+++ b/examples/react/nextjs-app-optimistic-updates/app/layout.tsx
@@ -0,0 +1,22 @@
+import Providers from './providers'
+import type React from 'react'
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'TanStack Query — Optimistic Updates',
+ description: 'Next.js App Router example with optimistic updates',
+}
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/examples/react/nextjs-app-optimistic-updates/app/page.tsx b/examples/react/nextjs-app-optimistic-updates/app/page.tsx
new file mode 100644
index 0000000000..9b0ffe691f
--- /dev/null
+++ b/examples/react/nextjs-app-optimistic-updates/app/page.tsx
@@ -0,0 +1,27 @@
+import React from 'react'
+import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
+import { getQueryClient } from '@/app/get-query-client'
+import { getTodos } from '@/app/api/todos/data'
+import ApproachTabs from '@/components/ApproachTabs'
+
+export default function Home() {
+ const queryClient = getQueryClient()
+
+ void queryClient.prefetchQuery({
+ queryKey: ['todos'],
+ queryFn: getTodos,
+ })
+
+ return (
+
+
Optimistic Updates with TanStack Query
+
+ Add todos to see optimistic updates in action. The server randomly fails
+ ~30% of the time so you can observe automatic rollback.
+
+ Approach 2 — via cache manipulation:onMutate{' '}
+ snapshots the cache and writes the optimistic item in. onError{' '}
+ restores the snapshot on failure.
+
+ )
+}
diff --git a/examples/react/nextjs-app-optimistic-updates/components/TodoListUI.tsx b/examples/react/nextjs-app-optimistic-updates/components/TodoListUI.tsx
new file mode 100644
index 0000000000..be867ced18
--- /dev/null
+++ b/examples/react/nextjs-app-optimistic-updates/components/TodoListUI.tsx
@@ -0,0 +1,96 @@
+'use client'
+
+import React, { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import type { Todo } from '@/app/api/todos/data'
+
+async function fetchTodos(): Promise> {
+ const res = await fetch('/api/todos')
+ if (!res.ok) throw new Error('Failed to fetch todos')
+ return res.json()
+}
+
+async function addTodo(text: string): Promise {
+ const res = await fetch('/api/todos', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text }),
+ })
+ if (!res.ok) {
+ const err = (await res.json()) as { error: string }
+ throw new Error(err.error)
+ }
+ return res.json()
+}
+
+export default function TodoListUI() {
+ const queryClient = useQueryClient()
+ const [inputValue, setInputValue] = useState('')
+
+ const { data: todos = [] } = useQuery({
+ queryKey: ['todos'],
+ queryFn: fetchTodos,
+ })
+
+ const addTodoMutation = useMutation({
+ mutationFn: addTodo,
+ onSettled: () => {
+ void queryClient.invalidateQueries({ queryKey: ['todos'] })
+ },
+ })
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ const text = inputValue.trim()
+ if (!text) return
+ addTodoMutation.mutate(text)
+ setInputValue('')
+ }
+
+ return (
+
+
+ Approach 1 — via UI variables: The pending item is
+ rendered directly from mutation.variables. No cache
+ manipulation needed. On error the pending item simply disappears.
+