By default a ChatClient (and every framework useChat/createChat wrapper) keeps messages in memory only — reload the page or navigate away and the conversation is gone. The optional persistence adapter wires the client to a storage backend so conversations survive reloads, with no manual initialMessages + onFinish boilerplate.
This is especially useful for SPAs, Electron apps, and offline-first setups where the client is the source of truth and there's no server managing conversation state.
A persistence adapter is any object with three methods — the same getItem/setItem/removeItem shape used elsewhere in TanStack AI. Each method may be synchronous or return a Promise:
import type { ChatClientPersistence } from "@tanstack/ai-client";
interface ChatClientPersistence {
getItem: (
id: string,
) =>
| Array<UIMessage>
| null
| undefined
| Promise<Array<UIMessage> | null | undefined>;
setItem: (id: string, messages: Array<UIMessage>) => void | Promise<void>;
removeItem: (id: string) => void | Promise<void>;
}import type { ChatClientPersistence } from "@tanstack/ai-client";
interface ChatClientPersistence {
getItem: (
id: string,
) =>
| Array<UIMessage>
| null
| undefined
| Promise<Array<UIMessage> | null | undefined>;
setItem: (id: string, messages: Array<UIMessage>) => void | Promise<void>;
removeItem: (id: string) => void | Promise<void>;
}The id passed to each method is the client's id option. Provide a stable id per conversation so the right history is loaded back:
const client = new ChatClient({
id: "conversation-123",
connection: adapter,
persistence: myPersistenceAdapter,
});const client = new ChatClient({
id: "conversation-123",
connection: adapter,
persistence: myPersistenceAdapter,
});When a persistence adapter is provided, ChatClient:
When persistence is omitted, nothing changes — the client behaves exactly as before. The option is fully backwards compatible.
Persistence is best-effort: if an adapter method throws or rejects, the error is swallowed so storage problems never break the chat. Handle and surface errors inside your adapter if you need to react to them.
Every framework wrapper accepts the same persistence option and forwards it to the underlying ChatClient:
// React / Preact
const chat = useChat({
id: "conversation-123",
connection: fetchServerSentEvents("/api/chat"),
persistence: myPersistenceAdapter,
});// React / Preact
const chat = useChat({
id: "conversation-123",
connection: fetchServerSentEvents("/api/chat"),
persistence: myPersistenceAdapter,
});// Solid / Vue — same option
const chat = useChat({
id: "conversation-123",
connection: fetchServerSentEvents("/api/chat"),
persistence: myPersistenceAdapter,
});// Solid / Vue — same option
const chat = useChat({
id: "conversation-123",
connection: fetchServerSentEvents("/api/chat"),
persistence: myPersistenceAdapter,
});// Svelte
const chat = createChat({
id: "conversation-123",
connection: fetchServerSentEvents("/api/chat"),
persistence: myPersistenceAdapter,
});// Svelte
const chat = createChat({
id: "conversation-123",
connection: fetchServerSentEvents("/api/chat"),
persistence: myPersistenceAdapter,
});A synchronous adapter backed by localStorage. Note that UIMessage.createdAt is a Date, which JSON.stringify turns into a string — revive it on read if you depend on it:
import type { ChatClientPersistence, UIMessage } from "@tanstack/ai-client";
const localStoragePersistence: ChatClientPersistence = {
getItem: (id) => {
const raw = window.localStorage.getItem(id);
if (!raw) return null;
return (JSON.parse(raw) as Array<UIMessage>).map((message) => ({
...message,
createdAt:
typeof message.createdAt === "string"
? new Date(message.createdAt)
: message.createdAt,
}));
},
setItem: (id, messages) => {
window.localStorage.setItem(id, JSON.stringify(messages));
},
removeItem: (id) => {
window.localStorage.removeItem(id);
},
};import type { ChatClientPersistence, UIMessage } from "@tanstack/ai-client";
const localStoragePersistence: ChatClientPersistence = {
getItem: (id) => {
const raw = window.localStorage.getItem(id);
if (!raw) return null;
return (JSON.parse(raw) as Array<UIMessage>).map((message) => ({
...message,
createdAt:
typeof message.createdAt === "string"
? new Date(message.createdAt)
: message.createdAt,
}));
},
setItem: (id, messages) => {
window.localStorage.setItem(id, JSON.stringify(messages));
},
removeItem: (id) => {
window.localStorage.removeItem(id);
},
};For larger histories or structured queries, back the adapter with an async store such as IndexedDB. The client awaits async methods automatically:
import type { ChatClientPersistence } from "@tanstack/ai-client";
const indexedDbPersistence: ChatClientPersistence = {
getItem: async (id) => {
const record = await db.conversations.get(id);
return record?.messages;
},
setItem: async (id, messages) => {
await db.conversations.put({ id, messages, updatedAt: Date.now() });
},
removeItem: async (id) => {
await db.conversations.delete(id);
},
};import type { ChatClientPersistence } from "@tanstack/ai-client";
const indexedDbPersistence: ChatClientPersistence = {
getItem: async (id) => {
const record = await db.conversations.get(id);
return record?.messages;
},
setItem: async (id, messages) => {
await db.conversations.put({ id, messages, updatedAt: Date.now() });
},
removeItem: async (id) => {
await db.conversations.delete(id);
},
};Any backend works — IndexedDB, SQLite (Electron/Tauri), a remote database, or an in-memory Map for tests — as long as it implements the three methods.