@tanstack/ai
ai-core/client-persistence
sub-skillBrowser chat persistence on useChat / ChatClient: localStoragePersistence, sessionStoragePersistence, indexedDBPersistence. Client-authoritative (adapter, full transcript) vs server-authoritative (persistence: true, no client cache). Reload restore, pending interrupts, mid-stream rejoin with delivery durability. Use for SPA reload durability — NOT server history alone. Also covers generation hooks (useGenerateImage etc.), which take only the server-driven mode: persistence: true hydrates the last generation for the (REQUIRED) threadId from the server on mount and repaints status/result/error, nothing is cached in the browser. No extra package: the adapters ship in the framework packages.
Client Persistence
Builds on ai-core, and on ai-core/chat-experience for useChat itself.
No extra package. The adapters below ship in the framework packages (@tanstack/ai-react and friends, re-exported from @tanstack/ai-client), so browser persistence needs nothing installed beyond what a chat UI already has. The server half is a separate package — see @tanstack/ai-persistence and its ai-persistence/server skill.
A ChatClient / useChat keeps messages in memory. The persistence option stores one record per threadId so a reload can repaint the transcript, restore a pending interrupt, and rejoin an in-flight run.
Import adapters from the framework package (not @tanstack/ai-client unless vanilla JS):
import {
useChat,
fetchServerSentEvents,
localStoragePersistence,
sessionStoragePersistence,
indexedDBPersistence,
} from '@tanstack/ai-react'Adapters
| Adapter | Survives | Notes |
|---|---|---|
| localStoragePersistence() | Reloads + browser restarts | Sync hydrate; quota-bound; JSON codec default |
| sessionStoragePersistence() | Reloads in the same tab | Cleared when tab/session ends |
| indexedDBPersistence() | Reloads + restarts | Async open (first paint may be empty briefly); structured clone |
All default to the chat persisted-state shape — no type argument or codec required for normal use.
Mode A — cache everything (client-authoritative)
import {
useChat,
fetchServerSentEvents,
localStoragePersistence,
} from '@tanstack/ai-react'
function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat', // stable — required
connection: fetchServerSentEvents('/api/chat'),
persistence: localStoragePersistence(),
})
// ...
}Bare adapter ≡ full transcript + resume pointer. Browser owns history; server (if any) mirrors when you post non-empty messages.
Best for: SPA, offline-first, single device, moderate conversation size.
Mode B — server-authoritative (persistence: true)
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
function Chat({ threadId }: { threadId: string }) {
const { messages, sendMessage } = useChat({
threadId,
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
})
// ...
}Nothing is cached client-side: no transcript, no resume pointer.
On mount, useChat hydrates the thread from the server by threadId (paint + tail active run). Same path for another device. Pair with server withPersistence + a hydrate route (reconstructChat or equivalent).
Best for: large transcripts, multi-device, compliance (no message bodies in browser storage).
What a reload restores
- Finished run — transcript from the adapter (mode A) or server (mode B).
- Paused on interrupt — approval UI restored (from the adapter in mode A, the server hydrate in mode B).
- Still streaming — needs delivery durability on the route (toServerSentEventsResponse(stream, { durability: … })) so the client can joinRun and finish the reply. Persistence alone is not enough.
Stable threadId is the identity
Persistence keys on threadId. The hooks have no separate id option — a chat's identity is its threadId. Without a stable one, each load is a new chat. Generate it server-side or from a route param the user owns; do not randomize per mount.
Generation hooks: server-driven only
The generation hooks (useGenerateImage, useGenerateVideo, useGeneration, useSummarize, useTranscription, …) take a persistence option too, but it is boolean only — there is no storage-adapter mode, and the browser caches nothing. The hooks are transparent, mirroring useChat: a reload repaints the hook's normal fields — status ('idle' / 'generating' / 'success' / 'error'), error, and result — as if the run had just finished. There is no resumeSnapshot, resumeState, pendingArtifacts, or resultArtifacts field. The one extra field is runId: the id of the generation job currently running, or null when nothing is in flight. The persisted record holds run identity, status, error, and result metadata (ids, model, a provider video job id), never the generated media bytes.
The hook return is exactly generate / result / isLoading / error / status / stop / reset / runId.
Turning it on (persistence: true)
import { useGenerateImage, fetchServerSentEvents } from '@tanstack/ai-react'
function ImageGenerator({ threadId }: { threadId: string }) {
const image = useGenerateImage({
threadId, // REQUIRED — the scope the last generation is hydrated under
connection: fetchServerSentEvents('/api/generate/image'),
persistence: true,
})
// After a reload: image.status / image.result / image.error are the last
// generation for `threadId`, fetched from the server — nothing was cached.
}The server half — the same route handles the run and the hydration GET:
import {
generateImage,
generationParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import {
memoryPersistence,
reconstructGeneration,
withGenerationPersistence,
} from '@tanstack/ai-persistence'
// Needs `stores.generationRuns`; `memoryPersistence()` ships one.
const persistence = memoryPersistence()
export async function POST(request: Request) {
const { input, threadId } = await generationParamsFromRequest(
'image',
request,
)
if (typeof input.prompt !== 'string') {
throw new Error('This endpoint accepts text image prompts only.')
}
if (threadId === undefined) {
throw new Error('Generation persistence requires a `threadId`.')
}
return toServerSentEventsResponse(
generateImage({
adapter: openaiImage('gpt-image-2'),
prompt: input.prompt,
// The stable slot this run fills. Required by persistence: the run record
// is filed under it, and the client hydrates by it on mount.
threadId,
stream: true,
middleware: [withGenerationPersistence(persistence)],
}),
)
}
// Mount-time hydration: resolves `?runId=` (preferred) or the latest run linked
// to `?threadId=`, and returns `{ resumeSnapshot, activeRun }`.
export function GET(request: Request) {
return reconstructGeneration(persistence, request, {
// Multi-user routes MUST authorize: the ids come from the caller. Derive
// identity from server-side session state, then check ownership.
authorize: async (id, req) => {
// const user = await auth(req)
// return user != null && (await db.threadOwnedBy(user.id, id))
void id
void req
return true
},
})
}- Nothing is cached client-side. On mount the client hydrates the last generation for its threadId from the server via the connection's hydrateGeneration handler (the SSE/HTTP adapters issue a GET with ?threadId= to the same endpoint URL) and repaints it into the normal fields.
- The server GET returns reconstructGeneration(persistence, request) from @tanstack/ai-persistence — it resolves the run by ?runId= (preferred) or the latest run linked to ?threadId=, and needs stores.generationRuns. Pair it with withGenerationPersistence on the generation route. See ai-core/media-generation and ai-persistence.
- Best for multi-device / compliance (no generation metadata in browser storage), exactly like chat's server-authoritative mode.
Restoring media: byte storage + artifactUrl
result comes back with its media only when the server persists the bytes (stores.artifacts + stores.blobs) AND withGenerationPersistence is given an artifactUrl mapper:
withGenerationPersistence(persistence, {
artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`,
})artifactUrl stamps a durable app-origin URL onto each persisted ref and rewrites the live result's media to it, so live and restored results match. The durable refs travel on result.artifacts; on restore the hook rebuilds result from them, so result.images[i].url (or a video's result.url) serves from your own origin. result.artifacts is the whole artifact surface on the hook. Without byte storage, a reload restores status / error and result stays null.
Also worth knowing:
- stop() marks the record no longer resumable; reset() clears the in-memory snapshot.
- Nothing auto-runs from a hydrated record — generate(...) is always explicit.
- Use status / result for a finished run; use runId to tell that a run was still generating when the page closed, and to name it to your own server (to cancel or poll the provider job — stop() only aborts the local stream).
Common mistakes
HIGH: No threadId
Record cannot be found after reload.
HIGH: Passing id to useChat
Removed — threadId is the identity. (ChatClient still accepts id directly as a lower-level escape hatch for keying storage separately from the wire thread; the framework hooks do not.)
HIGH: persistence: true without server history
Empty chat after reload unless the server can reconstruct by threadId.
MEDIUM: Huge transcripts in localStorage
Quota and main-thread cost. Prefer persistence: true + server store, or IndexedDB with care.
MEDIUM: Expecting multi-device sync from client storage alone
localStorage is per-browser. Use server persistence for multi-device.
Cross-references
- ai-persistence/server (@tanstack/ai-persistence) — authoritative server half
- ai-core/chat-experience — useChat, resumable connections
- Resumable streams docs — mid-stream rejoin