A ChatClient (and every framework useChat / createChat) keeps messages in memory, so a reload or a crashed tab loses the whole conversation and any reply that was still streaming. The persistence option fixes that from the browser side: on reload it repaints the transcript, brings back a pending interrupt, and rejoins a run that was mid-stream.
You need this whether or not you have a server:
Pass a storage adapter as persistence and give the chat a stable threadId so a reload finds the same record:
import {
fetchServerSentEvents,
localStoragePersistence,
useChat,
} from '@tanstack/ai-react'
function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat',
connection: fetchServerSentEvents('/api/chat'),
persistence: localStoragePersistence(),
})
// ...render messages, call sendMessage(text)
}localStoragePersistence() needs no type argument and no codec: it defaults to the chat record shape and a JSON codec. That is the whole opt-in.
The client stores one record per threadId, the transcript plus a small resume pointer. On the next load useChat reads it and:
persistence takes a storage adapter or a boolean:
Pass the adapter directly, persistence: localStoragePersistence(). The transcript and the resume pointer both live in the browser. The client owns the history; the server, if any, mirrors it. Best when the browser is the source of truth: single-page apps, offline-first, one device, small to moderate conversations.
Pass persistence: true. The client stores nothing, no transcript and no resume pointer. On mount useChat hydrates the thread from the server by its threadId: it paints the stored transcript and, if a run is still generating, tails it to completion. Best when transcripts are large (localStorage is synchronous and quota-bound), when the same conversation must open on another device, or when you simply do not want message content in the browser.
You do not fetch or seed the transcript yourself. A reload and the same thread opened on another device follow the identical path, because the thread id is the stable key and the server resolves everything from it. No loader, no initialMessages, no extra props. It needs a connection with a hydrate handler (every built-in connection has one) and the server GET endpoint below.
Client — a connection, a stable threadId, and persistence: true:
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
const connection = fetchServerSentEvents('/api/chat')
function Chat({ threadId }: { threadId: string }) {
const { messages, sendMessage } = useChat({
threadId,
connection,
persistence: true,
})
return (
<div>
{messages.map((m) => (
<div key={m.id}>{m.role}</div>
))}
<button type="button" onClick={() => void sendMessage('hi')}>
Send
</button>
</div>
)
}Server — one GET endpoint next to your chat POST. Replay the durability log when the request carries a resume cursor, otherwise return the stored conversation with reconstructChat:
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 carries a resume cursor (Last-Event-ID / ?offset and
// X-Run-Id / ?runId). Replay the log so the run finishes in place.
if (durability.resumeFrom() !== null) {
return resumeServerSentEventsResponse({ adapter: durability })
}
// Otherwise return the stored transcript plus a cursor to any in-flight run.
// Guard access in multi-user apps (see authorize in Chat persistence).
return reconstructChat(persistence, request)
}reconstructChat returns { messages, activeRun }: the transcript as UI messages, and activeRun when a run is still generating for the thread. The client calls this endpoint on mount and, when activeRun is set, tails the run through the replay branch above. You never handle a run id, and a second device resumes the live run the same way the original tab does. See Chat persistence.
| Mode | Caches on client | Authoritative history | Reach for it when |
|---|---|---|---|
| persistence: store | transcript + resume pointer | client | SPA / offline, one device, small to moderate history |
| persistence: true | nothing | server | large histories, multi-device, no transcripts in the browser |
Three adapters ship from @tanstack/ai-client, re-exported from every framework package. All share the same shape; they differ in lifetime and encoding.
| Adapter | Lifetime | Notes | Reach for it when |
|---|---|---|---|
| localStoragePersistence | across reloads and browser restarts | synchronous, ~5MB quota, JSON codec | the default: persist a conversation for next time |
| sessionStoragePersistence | one tab, cleared when it closes | same shape as localStorage | a conversation that should not outlive the tab |
| indexedDBPersistence | across reloads and restarts | async, structured clone (a Date round-trips exactly), room for large data | big transcripts, or values a JSON codec would mangle |
import { indexedDBPersistence } from '@tanstack/ai-react'
const persistence = indexedDBPersistence()Each throws only lazily, per operation, when its backing store is missing (for example during server-side rendering), so constructing one on the server is safe.
Any object with getItem / setItem / removeItem works. The record is one { messages, resume? } blob per chat id — the transcript plus the pointer that lets a reload rejoin an in-flight run — so setItem receives that whole 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)
// A bare array is the legacy messages-only format, still accepted.
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)
},
}Reads are best-effort: a getItem that throws or returns null is treated as "nothing stored", so an adapter that parses the wrong shape fails silently — the conversation just does not come back. Round-trip your adapter once against a real reload before shipping it.
Client persistence restores what one browser rendered. Server persistence (Chat persistence) keeps the authoritative copy for every user and survives a server restart. They compose: for the combination we recommend for most apps, and why, see the Persistence overview.