Your user reloads the page and the conversation is gone, because it only ever lived in memory. Or they open the app on their phone and none of it is there. Persistence fixes both, and it is two snippets: one middleware on the server, one option on the client.
There is a second, separate problem: the socket drops while a reply is still streaming. That is Resumable Streams, a different layer you can add on its own. Step 3 below combines them, which is what most apps end up wanting.
pnpm add @tanstack/ai-persistenceThe client half needs no install. It ships in the framework package you already use (@tanstack/ai-react, -vue, -solid, -svelte, -angular, or @tanstack/ai-client).
Wire this package's Agent Skills into your coding assistant before you write any of it, then ask it for "add chat persistence to this app":
npx @tanstack/intent@latest installRun that after the package is installed. Intent scans node_modules, so anything added later needs another run.
withPersistence writes the transcript, run status and any pending approvals into your own store. persistence here is your adapter; build one in about 40 lines, or start with memoryPersistence() for local dev.
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)
}Two forms, and the choice is only about who owns the history:
persistence: true puts the server in charge. The browser caches nothing and asks the server for the thread on mount. Best for multi-user and multi-device apps.
persistence: <adapter> puts the browser in charge, with localStoragePersistence(), sessionStoragePersistence() or indexedDBPersistence(). No server store needed.
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat',
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
// Or keep the transcript in the browser instead:
// persistence: localStoragePersistence(),
})
return <button onClick={() => sendMessage('hi')}>{messages.length}</button>
}With persistence: true the client needs one GET to read from, which is step 3. With a storage adapter you are done: reload and the conversation is there.
Add a GET to the same route. It does two jobs, and the if picks one per request: replay a run that is still streaming, or hand back the stored transcript.
import {
chatParamsFromRequest,
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 run still in flight: the client sent a resume offset, so replay its log.
if (durability.resumeFrom() !== null) {
return resumeServerSentEventsResponse({ adapter: durability })
}
// Otherwise return the stored thread, plus a cursor to any run still generating.
return reconstructChat(persistence, request, {
// WITHOUT this, anyone who guesses a thread id gets the whole transcript.
authorize: async (threadId, req) => ownsThread(req, threadId),
})
}
async function ownsThread(request: Request, threadId: string): Promise<boolean> {
void request
void threadId
return true // replace with your session and ownership check
}useChat drives both halves for you. On mount it fetches the transcript, and if the server reports a run still generating, it tails that run and the reply finishes in place. Nothing else to wire, and a second device follows the identical path.
To make the POST resumable too, hand the same adapter to the response: toServerSentEventsResponse(stream, { durability: { adapter: memoryStream(request) } }).
Generation (image, video, speech, transcription): the hooks take a persistence option too, boolean only, backed by a generationRuns store. See Generation persistence, and Keep generated files to hold the bytes after the provider's URLs expire.
Sandboxed agents: a run can outlive the tab and be adopted by another host. See Build a Sandbox Adapter for what to store, and Durable Runs for why.
| You want | Turn on |
|---|---|
| The conversation to survive a reload, nothing more | Step 2 with a storage adapter |
| The same conversation on another device, or after a server restart | Steps 1 and 2 with persistence: true |
| A reload mid-answer to pick the answer back up | Steps 1, 2 and 3 |
| A dropped socket to resume with the page still open | Resumable Streams alone |
| To pause for a human approval and resume it days later | Step 1 with an interrupts store |