TanStack
Persistence

Persistence Overview

Persistence

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.

Install

shell
pnpm add @tanstack/ai-persistence

The 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":

shell
npx @tanstack/intent@latest install

Run that after the package is installed. Intent scans node_modules, so anything added later needs another run.

1. Server: store the conversation

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.

ts
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)
}

2. Client: bring it back

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.
tsx
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.

3. Survive a reload mid-answer

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.

ts
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 and sandboxes use the same idea

  • 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.

Which setup do I want?

You wantTurn on
The conversation to survive a reload, nothing moreStep 2 with a storage adapter
The same conversation on another device, or after a server restartSteps 1 and 2 with persistence: true
A reload mid-answer to pick the answer back upSteps 1, 2 and 3
A dropped socket to resume with the page still openResumable Streams alone
To pause for a human approval and resume it days laterStep 1 with an interrupts store

Where to go next

  • Chat persistence: the server middleware in full, including durable interrupts.
  • Client persistence: the modes, storage backends, and what a reload restores in each case.
  • Build your own adapter: implement the stores against your database and prove them with the conformance suite.
  • Controls: compose backends per store.
  • How persistence works: the two layers, thread and run identity, who owns history, and the middleware lifecycle. Read it when something surprises you.