Three things can go wrong with an AI chat, and they have different fixes:
TanStack AI solves these with two independent layers. You can use either alone or both together.
The server half lives in one package. The client half needs no extra install — it ships with the framework package you already use (@tanstack/ai-react, -vue, -solid, -svelte, -angular, or @tanstack/ai-client).
pnpm add @tanstack/ai-persistenceThen wire this package's Agent Skills into your coding assistant, before you write any of it:
npx @tanstack/intent@latest installRun that after the package is installed, not before — Intent scans node_modules, so anything added later needs another run.
| Layer | Answers | Lives | Docs |
|---|---|---|---|
| Delivery durability | "how do I reconnect to a stream that's still running?" | a per-run log, keyed by runId | Resumable Streams |
| State persistence | "what is the conversation, and is it still there later?" | a durable store (client and/or server) | this section |
They share no code and solve different problems. Delivery durability replays a live byte stream so a dropped connection resumes exactly where it stopped. State persistence stores the conversation itself, so it survives a reload or exists on another device. A replayable stream is not a saved conversation, and a saved conversation is not a live stream. Real apps usually want both.
Persistence runs on the client, the server, or both. They are independent, and they answer different questions.
| Half | Stores | Survives | Use it for |
|---|---|---|---|
| Client (Client persistence) | with a storage adapter, the transcript + a resume pointer in localStorage / sessionStorage / IndexedDB; with persistence: true, nothing (hydrates from the server on mount) | a page reload in that browser | instant restore on reload, SPA / offline apps |
| Server (Chat persistence) | messages, run status, interrupts, in your own store | a server restart, and reaches every device | multi-device, audit, durable approvals |
Server persistence keys conversation history on threadId — the same conversation key as ChatMiddlewareContext.threadId and the required field of the shared Scope type from @tanstack/ai. Store APIs take a bare threadId string for adapter simplicity; multi-user isolation is still required:
Scope is re-exported from @tanstack/ai-persistence so apps can import the identity type next to the store contracts.
A minimal server setup adds one middleware to chat(). Here persistence comes from a local ./persistence module: a small adapter over a durable store that you build on the core in a few lines. See Build your own adapter for a complete SQLite implementation you can copy.
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { withPersistence } from '@tanstack/ai-persistence'
import { persistence } from './persistence'
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.resume ? { resume: params.resume } : {}),
middleware: [withPersistence(persistence)],
})
return toServerSentEventsResponse(stream)
}The client half is one option on useChat, persistence, and it takes two forms:
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat',
connection: fetchServerSentEvents('/api/chat'),
// Server owns history (pairs with withPersistence above):
persistence: true,
// Or keep the transcript in the browser instead:
// persistence: localStoragePersistence(),
})
// ...
}When both halves are on, one rule decides which copy is authoritative, and you pick it per turn by what the client sends as messages:
That single rule lets the two copies coexist without a merge conflict. Two postures come out of it:
With a client store adapter, on load useChat reads the client record and acts on what it finds:
A dropped connection while the page is still open is simpler: delivery durability reconnects on its own, no persistence needed. Persistence matters once the page itself is gone.
If you run server-authoritative with the transcript kept off the client (see Client persistence), the reload paints from a server read instead of localStorage. The delivery log cannot supply that history: it holds one run, not the whole thread.
| You want | Turn on |
|---|---|
| A dropped connection to resume the same answer | Delivery durability (Resumable Streams) |
| The conversation to still be there after a reload | Client persistence |
| Reload durability without caching big histories client-side | Client persistence with persistence: true |
| The same conversation on another device, or after a server restart | Server persistence (Chat persistence) |
| Pause for a human approval and resume it later, durably | Server persistence with an interrupts store |
| A mid-stream reload to pick up the live answer | Client persistence + delivery durability together |
Most production chat apps end up with all three: delivery durability on the route, client persistence for instant reload, and server persistence as the record of record.
For a real multi-user app, one combination beats the rest:
The server route:
import {
chat,
chatParamsFromRequest,
memoryStream,
resumeServerSentEventsResponse,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import {
reconstructChat,
withPersistence,
} from '@tanstack/ai-persistence'
import { persistence } from './persistence'
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.resume ? { resume: params.resume } : {}),
middleware: [withPersistence(persistence)],
})
return toServerSentEventsResponse(stream, {
durability: { adapter: memoryStream(request) },
})
}
export function GET(request: Request): Response | Promise<Response> {
const durability = memoryStream(request)
// In-flight run: the resume offset arrives via the Last-Event-ID header or
// ?offset, and the run id via the X-Run-Id header or ?runId, so ask the
// adapter with resumeFrom() instead of sniffing query params. Replay the log
// so a reload finishes the answer.
if (durability.resumeFrom() !== null) {
return resumeServerSentEventsResponse({ adapter: durability })
}
// Otherwise rehydrate the conversation from the durable store. `reconstructChat`
// reads `?threadId` and returns `{ messages, activeRun }` — the transcript plus
// a cursor to any run still generating.
//
// Security: without `authorize`, any caller who knows a thread id receives the
// full transcript. In multi-user apps, check session ownership here (or only
// ever pass a server-validated thread id).
return reconstructChat(persistence, request, {
authorize: async (threadId, req) => {
// Replace with your session + ownership check, e.g.:
// const user = await auth(req)
// return user != null && (await db.threadOwnedBy(user.id, threadId))
void threadId
void req
return true
},
})
}The client caches nothing. useChat calls that GET for you on mount:
import {
fetchServerSentEvents,
useChat,
} from '@tanstack/ai-react'
function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat',
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
})
// Nothing else to wire. On mount useChat fetches GET /api/chat?threadId=...,
// paints the returned transcript, and tails any run still generating.
}A mid-stream reload does both jobs off the same GET, and useChat drives both for you: it fetches the transcript (?threadId, the reconstruct branch), then, when reconstructChat reports an activeRun, tails that run (?offset=-1&runId, the resume branch). The if in the handler routes each request; one is never asked to do both. The replayed run's messages merge into the transcript by message id, so nothing is duplicated or lost. Because the reconnect is resolved from the stable threadId on the server, a reload and the same thread opened on another device resume the same way.
Why this wins over the alternatives:
Client-only persistence can't do multi-device and bloats storage. Caching everything client-side duplicates the source of truth. This combination avoids both: one server-resolved GET on mount restores history and rejoins any live run, so a reload and a second device follow the identical path.
Server state persistence is a set of stores. Middleware activates behavior from whichever stores are present (with entrypoint requirements — see Controls). There is no separate enable list.
| Store | Purpose |
|---|---|
| messages | Authoritative model-message history per thread. Required by chat persistence. |
| runs | Run status, timing, errors, and usage. Required on full ChatPersistence. |
| interrupts | Pending, resolved, or cancelled human/tool waits (needs runs). |
| metadata | App and integration key/value state. |
Named shapes: ChatTranscriptStores (messages floor), ChatPersistenceStores (all four), ChatWithInterruptsStores. See Controls.
Need a mutex across instances (cross-worker coordination)? Use withLocks and a LockStore from @tanstack/ai/locks; see Locks.
@tanstack/ai-persistence ships the contracts, the middleware, an in-memory reference backend, and a conformance testkit — not a backend for your database. You implement the stores against whatever you already run; Build your own adapter walks through a complete one.
If you ran intent install above, you can skip the typing: ask your assistant for "add chat persistence to this app" and the recipe matching your database loads itself. The full skill list is in Build your own adapter.