Client persistence writes useChat messages to storage in the browser. A reload keeps the conversation and a reply that was still streaming.
On reload, the persistence option also brings back a pending approval.
Pick who owns the history. Then copy that path.
Use this for a single-page app, an offline-first app, or any app with no server store. Pass a storage adapter and a stable threadId:
import {
fetchServerSentEvents,
localStoragePersistence,
useChat,
} from '@tanstack/ai-react'
export function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat',
connection: fetchServerSentEvents('/api/chat'),
persistence: localStoragePersistence(),
})
return (
<div>
{messages.map((message) => (
<div key={message.id}>{message.role}</div>
))}
<button type="button" onClick={() => void sendMessage('hi')}>
Send
</button>
</div>
)
}localStoragePersistence() needs no type argument and no codec. It uses the chat record shape and a JSON codec by default.
Send a message and reload. The conversation is there, with no network call.
Use this for large transcripts, for the same chat on more than one device, or to keep message content out of the browser. You need Chat persistence on the server first.
Set persistence: true in the client:
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
export function Chat({ threadId }: { threadId: string }) {
const { messages, isHydrating, sendMessage } = useChat({
threadId,
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
})
if (isHydrating) return <p>Loading conversation...</p>
return (
<div>
{messages.map((message) => (
<div key={message.id}>{message.role}</div>
))}
<button type="button" onClick={() => void sendMessage('hi')}>
Send
</button>
</div>
)
}Add a GET next to your chat POST:
import { memoryStream, resumeServerSentEventsResponse } from '@tanstack/ai'
import { reconstructChat } from '@tanstack/ai-persistence'
import { persistence } from './persistence'
export function GET(request: Request): Response | Promise<Response> {
const durability = memoryStream(request)
// A reconnecting client sends a resume cursor. Replay the log.
if (durability.resumeFrom() !== null) {
return resumeServerSentEventsResponse({ adapter: durability })
}
// Otherwise return the stored thread, plus any run that still streams.
return reconstructChat(persistence, request, {
// Without this, anyone who guesses a thread id gets the transcript.
authorize: async (threadId, req) => ownsThread(req, threadId),
})
}
async function ownsThread(request: Request, threadId: string): Promise<boolean> {
void request
void threadId
return true // Replace with your session and ownership check.
}The replay branch works only when your POST records the stream. To record it, pass { durability: { adapter: memoryStream(request) } } to toServerSentEventsResponse in the POST. The overview quick setup shows the full route.
Reload the page. useChat loads the thread from the server. Open the same threadId on another device, and you get the same chat.
You do not fetch or seed the transcript yourself. You need no loader and no initialMessages. The threadId is the stable key, and the server finds everything from it.
The threadId is the key that the record is saved under. A new id on each mount restores nothing. Take it from your own data, for example a conversation id or a route param. Id map explains how to choose one, and how it differs from the runId that the hook reports.
Replay rebuilds reasoning, tool activity, and text from the durable log. This includes runs that emit reasoning before their first tool or text event. The completed activity stays in the transcript that the next message sends.
persistence takes a storage adapter or a boolean:
| Value | Who owns the history | What the client caches | Use it for |
|---|---|---|---|
| A storage adapter | The client | The transcript and a resume pointer | Single-page and offline apps, one device, small to medium history |
| true | The server | Nothing | Large histories, many devices, no transcripts in the browser |
| false or omitted | Nobody | Nothing | A chat that can start empty after a reload |
The generation hooks take the same option, but as a boolean only. See Generation persistence.
Client and server persistence are independent:
For the setup that most apps use, see Which setup do I want?.
Three adapters ship in @tanstack/ai-client. Every framework package exports them again. They all have the same shape:
| Adapter | How long it keeps data | Notes | Use it for |
|---|---|---|---|
| localStoragePersistence | Across reloads and browser restarts | Synchronous, about 5MB quota, JSON codec | The default: keep a conversation for next time |
| sessionStoragePersistence | One tab, until the tab closes | Same shape as localStorage | A chat that must not outlive the tab |
| indexedDBPersistence | Across reloads and restarts | Async, structured clone (a Date comes back exactly), room for large data | Big transcripts, or values that a JSON codec changes |
import { indexedDBPersistence } from '@tanstack/ai-react'
const persistence = indexedDBPersistence()Each adapter throws only when you use it and its browser store is missing, for example during server-side rendering. So you can create one on the server safely.
With persistence: true, history: { pageSize } loads only the newest messages on hydrate. Call loadOlderMessages() for more:
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
const connection = fetchServerSentEvents('/api/chat')
function Chat({ threadId }: { threadId: string }) {
const {
messages,
isHydrating,
hasOlderMessages,
loadOlderMessages,
sendMessage,
} = useChat({
threadId,
connection,
persistence: true,
history: { pageSize: 50 },
})
if (isHydrating) return <p>Loading conversation...</p>
return (
<div>
{hasOlderMessages ? (
<button
type="button"
onClick={() => {
void loadOlderMessages().catch(() => {
// Show a retry. The messages on screen stay.
})
}}
>
Load older
</button>
) : null}
{messages.map((m) => (
<div key={m.id}>{m.role}</div>
))}
<button type="button" onClick={() => void sendMessage('hi')}>
Send
</button>
</div>
)
}The library does not watch the scrollbar. Call loadOlderMessages() from your own scroll handler.
isHydrating is true from the first render until the transcript is in place.
When the transcript appears depends on the adapter and the framework:
reconstructChat returns a JSON body with these fields:
You never handle a run id. A second device resumes the live run the same way the first tab does.
A live turn can time itself on the client. After a reload, that time is gone. Pass includeRuns: true to get it back from the server:
import { reconstructChat } from '@tanstack/ai-persistence'
import { persistence } from './persistence'
export function GET(request: Request): Promise<Response> {
return reconstructChat(persistence, request, { includeRuns: true })
}Each assistant message of a finished run then has the run timings, in epoch ms, on metadata.tanstack.run:
import type { UIMessage } from '@tanstack/ai'
function TurnDuration({ message }: { message: UIMessage }) {
const run = message.metadata?.tanstack?.run
if (!run?.finishedAt) return null
const seconds = Math.round((run.finishedAt - run.startedAt) / 1000)
return <span>Worked for {seconds}s</span>
}The response also has a top-level runs list with runId, status, startedAt, and finishedAt for each finished run. The timings need:
AG-UI activity events show as role: 'activity' rows in messages.
The next sendMessage does not send activity rows as model input.
A live client tool runs when its call arrives from the stream. Hydration restores its pending execution, but it does not run the browser code again, because that work is not always safe to repeat. The execution stays an internal interrupt, and it is not in interrupts.
Use onInterruptStateChange to tell restored interrupt state from live updates. By default, a restored batch stays pending. This example cancels every restored batch:
import { useEffect, useState } from 'react'
import {
fetchServerSentEvents,
localStoragePersistence,
useChat,
} from '@tanstack/ai-react'
function Chat() {
const [restoredBatch, setRestoredBatch] = useState(false)
const { cancelInterrupts } = useChat({
threadId: 'support-chat',
connection: fetchServerSentEvents('/api/chat'),
persistence: localStoragePersistence(),
onInterruptStateChange(_state, { source }) {
setRestoredBatch(source === 'hydrate')
},
})
useEffect(() => {
if (restoredBatch) cancelInterrupts()
}, [cancelInterrupts, restoredBatch])
return null
}cancelInterrupts() cancels the full internal batch. That includes visible approvals and hidden client-tool executions. It cannot cancel only one kind of interrupt.
Any object with getItem, setItem, and removeItem works. The record is one { messages, resume? } object per chat id: the transcript, plus the pointer that lets a reload rejoin a running reply. So setItem gets that full record, not a bare message array:
import type {
ChatClientPersistence,
ChatPersistedState,
} from '@tanstack/ai-client'
function isPersistedState(value: unknown): value is ChatPersistedState {
return (
typeof value === 'object' &&
value !== null &&
'messages' in value &&
Array.isArray(value.messages)
)
}
const persistence: ChatClientPersistence = {
getItem(id) {
const raw = localStorage.getItem(id)
if (raw === null) return null
const parsed: unknown = JSON.parse(raw)
// Wrap a bare message array saved by a 0.x app.
if (Array.isArray(parsed)) return { messages: parsed }
return isPersistedState(parsed) ? parsed : null
},
setItem(id, state) {
localStorage.setItem(id, JSON.stringify(state))
},
removeItem(id) {
localStorage.removeItem(id)
},
}Warning
Reads are best effort. If getItem throws or returns null, the client treats it as "nothing stored". So an adapter that parses the wrong shape fails silently, and the conversation does not come back. Do one real reload with your adapter before you ship it.