Durability and state persistence for TanStack AI chats with @tanstack/ai-persistence. Routes to server chat persistence (withPersistence), client persistence (localStorage/IndexedDB), the store contracts, and adapter recipes. Distinguishes delivery durability (resumable streams) from conversation state. Use when conversations must survive reloads, multi-device, approvals, or server restarts — NOT for stream reconnect alone.
75
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
Builds on the
ai-coreskill in@tanstack/ai, and usuallyai-core/chat-experience.
TanStack AI splits delivery durability from state persistence. They share no code and solve different problems.
| Layer | Answers | Package / API |
|---|---|---|
| Delivery durability | Reconnect to a stream still running | memoryStream / @tanstack/ai-durable-stream on the response; see resumable streams docs |
| State persistence | What is the conversation, later? | Client persistence on useChat + server withPersistence from @tanstack/ai-persistence |
A replayable stream is not a saved conversation. A saved conversation is not a live stream. Production apps often use both.
@tanstack/ai-persistence ships the store interfaces, the middleware that
drives them, an in-memory reference backend, and a conformance testkit. It does
not ship a backend for your database, and you do not need one: implement the
stores against whatever you already run — Postgres, SQLite, D1, Mongo — and hand
the result to withPersistence. The core never inspects your tables.
| Ships in the package | What it is |
|---|---|
MessageStore / RunStore / InterruptStore / MetadataStore | The four state contracts |
withPersistence / withGenerationPersistence | Chat + generation middleware |
memoryPersistence() | In-process reference backend (dev, tests) |
reconstructChat | Server hydrate route helper |
LockStore / withLocks / InMemoryLockStore (from @tanstack/ai/locks) | Coordination, not this package — see ai-core/locks |
@tanstack/ai-persistence/testkit | runPersistenceConformance compatibility gate |
| Need to... | Read |
|---|---|
| Wire server-side chat history, runs, interrupts | ai-persistence/server/SKILL.md |
| Survive reloads in the browser | ai-core/client-persistence/SKILL.md in @tanstack/ai |
| Implement the store interfaces for your DB | ai-persistence/stores/SKILL.md |
| Multi-instance locks (separate from state) | ai-core/locks/SKILL.md in @tanstack/ai |
Adding persistence to an app? Pick the recipe that matches what it already
runs — each one writes a single chat-persistence.ts against the app's
existing database client and schema:
| The app runs... | Read |
|---|---|
| Drizzle ORM (SQLite / Postgres / MySQL) | ai-persistence/build-drizzle-adapter/SKILL.md |
| Prisma | ai-persistence/build-prisma-adapter/SKILL.md |
| Cloudflare Workers + D1 (± Durable Object locks) | ai-persistence/build-cloudflare-adapter/SKILL.md |
Anything else — raw pg, Kysely, SQLite, Mongo | ai-persistence/build-custom-adapter/SKILL.md |
| Half | Stores | Survives | Typical use |
|---|---|---|---|
| Client | transcript ± resume pointer in browser storage | reload / tab close (per browser) | SPA restore, offline-first |
| Server | messages, runs, interrupts, metadata in your DB | restart + multi-device | authoritative history, durable approvals |
They are independent. Use either alone or both.
threadId and ScopeServer stores key on threadId (same as chat({ threadId }) /
ChatMiddlewareContext.threadId / Scope.threadId from @tanstack/ai).
threadId strings for adapter simplicity.userId / tenantId from
session server-side; authorize before load/save / reconstructChat.When both halves run, ownership per turn is decided by request messages:
| Client sends | Meaning | On finish |
|---|---|---|
Non-empty messages | Full transcript (source of truth) | Server overwrites stored thread |
Empty messages | Continue from server copy | Server loads stored thread |
Never post a delta as messages — that wipes history down to the delta.
Client-authoritative: always send full transcript; browser is truth, server mirrors.
Server-authoritative: send empty messages (or hydrate via server load); server is truth, multi-device works.
persistence: true — server-authoritative, no client cache.withPersistence(backend) — messages + runs + interrupts.withLocks(distributedLockStore) from @tanstack/ai/locks when other middleware needs multi-instance coordination (not part of the state bag).Server
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { withPersistence } from '@tanstack/ai-persistence'
// Your adapter — see ai-persistence/stores.
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)
}Client (server-authoritative)
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
function Chat({ threadId }: { threadId: string }) {
const { messages, sendMessage } = useChat({
threadId,
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
})
// ...
}With persistence: true, the client caches nothing and hydrates the transcript
from the server on mount (thread id is the key). Pair with a server load path
such as reconstructChat for the GET.
@tanstack/ai-persistence + middleware, not Vercel useChat storage hacks.saveThread is full overwrite, never append.createOrResume is insert-if-absent for the same runId.create is insert-if-absent — never clobber resolved → pending.withLocks from @tanstack/ai/locks. Sandbox resume is a sandbox-package concern — not a stores key. stores accepts only messages, runs, interrupts, metadata.@tanstack/ai) — useChat, SSE, client persistence option overview@tanstack/ai) — middleware hooks; withPersistence is a ChatMiddleware1120f0f
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.