Persistence

Client Persistence

Client Persistence

A ChatClient (and every framework useChat / createChat) keeps messages in memory, so a reload or a crashed tab loses the whole conversation and any reply that was still streaming. The persistence option fixes that from the browser side: on reload it repaints the transcript, brings back a pending interrupt, and rejoins a run that was mid-stream.

You need this whether or not you have a server:

  • The browser owns the chat (SPA, offline-first, no server store). Pass a storage adapter and it holds the full transcript, restored on reload with no network.
  • The server owns the chat (you use Chat persistence). Set persistence: true and the client caches nothing: on reload it hydrates the thread from the server by its threadId, painting the conversation and rejoining any run still streaming. That is the server-authoritative mode below.

Turn it on

Pass a storage adapter as persistence and give the chat a stable threadId so a reload finds the same record:

tsx
import {
  fetchServerSentEvents,
  localStoragePersistence,
  useChat,
} from '@tanstack/ai-react'

function Chat() {
  const { messages, sendMessage } = useChat({
    threadId: 'support-chat',
    connection: fetchServerSentEvents('/api/chat'),
    persistence: localStoragePersistence(),
  })
  // ...render messages, call sendMessage(text)
}

localStoragePersistence() needs no type argument and no codec: it defaults to the chat record shape and a JSON codec. That is the whole opt-in.

What a reload restores

The client stores one record per threadId, the transcript plus a small resume pointer. On the next load useChat reads it and:

  • Repaints the transcript from storage with no network. Sync adapters (localStorage / sessionStorage) hydrate during construction; IndexedDB hydrates asynchronously after the database opens (so the first paint may be empty for a tick).
  • Rehydrates a pending interrupt, so an approval prompt comes back exactly as it was.
  • Rejoins an in-flight run, if a reply was still streaming when the page reloaded, so it finishes in place instead of freezing half-done. This one needs a durability-backed connection (a route that records the stream and exposes a replay handler); see Resumable streams.

Choose a mode

persistence takes a storage adapter or a boolean:

  • an adapter (persistence: localStoragePersistence()) is client-authoritative.
  • true is server-authoritative.
  • false (or omitted) is off: messages live in memory only and a reload starts empty.

An adapter: client-authoritative

Pass the adapter directly, persistence: localStoragePersistence(). The transcript and the resume pointer both live in the browser. The client owns the history; the server, if any, mirrors it. Best when the browser is the source of truth: single-page apps, offline-first, one device, small to moderate conversations.

true: server-authoritative

Pass persistence: true. The client stores nothing, no transcript and no resume pointer. On mount useChat hydrates the thread from the server by its threadId: it paints the stored transcript and, if a run is still generating, tails it to completion. Best when transcripts are large (localStorage is synchronous and quota-bound), when the same conversation must open on another device, or when you simply do not want message content in the browser.

You do not fetch or seed the transcript yourself. A reload and the same thread opened on another device follow the identical path, because the thread id is the stable key and the server resolves everything from it. No loader, no initialMessages, no extra props. It needs a connection with a hydrate handler (every built-in connection has one) and the server GET endpoint below.

Client — a connection, a stable threadId, and persistence: true:

tsx
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'

const connection = fetchServerSentEvents('/api/chat')

function Chat({ threadId }: { threadId: string }) {
  const { messages, sendMessage } = useChat({
    threadId,
    connection,
    persistence: true,
  })
  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>{m.role}</div>
      ))}
      <button type="button" onClick={() => void sendMessage('hi')}>
        Send
      </button>
    </div>
  )
}

Server — one GET endpoint next to your chat POST. Replay the durability log when the request carries a resume cursor, otherwise return the stored conversation with reconstructChat:

ts
import { 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 reconnecting client carries a resume cursor (Last-Event-ID / ?offset and
  // X-Run-Id / ?runId). Replay the log so the run finishes in place.
  if (durability.resumeFrom() !== null) {
    return resumeServerSentEventsResponse({ adapter: durability })
  }
  // Otherwise return the stored transcript plus a cursor to any in-flight run.
  // Guard access in multi-user apps (see authorize in Chat persistence).
  return reconstructChat(persistence, request)
}

reconstructChat returns { messages, activeRun }: the transcript as UI messages, and activeRun when a run is still generating for the thread. The client calls this endpoint on mount and, when activeRun is set, tails the run through the replay branch above. You never handle a run id, and a second device resumes the live run the same way the original tab does. See Chat persistence.

ModeCaches on clientAuthoritative historyReach for it when
persistence: storetranscript + resume pointerclientSPA / offline, one device, small to moderate history
persistence: truenothingserverlarge histories, multi-device, no transcripts in the browser

Choose a storage backend

Three adapters ship from @tanstack/ai-client, re-exported from every framework package. All share the same shape; they differ in lifetime and encoding.

AdapterLifetimeNotesReach for it when
localStoragePersistenceacross reloads and browser restartssynchronous, ~5MB quota, JSON codecthe default: persist a conversation for next time
sessionStoragePersistenceone tab, cleared when it closessame shape as localStoragea conversation that should not outlive the tab
indexedDBPersistenceacross reloads and restartsasync, structured clone (a Date round-trips exactly), room for large databig transcripts, or values a JSON codec would mangle
tsx
import { indexedDBPersistence } from '@tanstack/ai-react'

const persistence = indexedDBPersistence()

Each throws only lazily, per operation, when its backing store is missing (for example during server-side rendering), so constructing one on the server is safe.

Writing your own

Any object with getItem / setItem / removeItem works. The record is one { messages, resume? } blob per chat id — the transcript plus the pointer that lets a reload rejoin an in-flight run — so setItem receives that whole record, not a bare message array:

ts
import type {
  ChatClientPersistence,
  ChatPersistedState,
} from '@tanstack/ai-client'

function isPersistedState(value: unknown): value is ChatPersistedState {
  return (
    typeof value === 'object' &&
    value !== null &&
    'messages' in value &&
    Array.isArray(value.messages)
  )
}

const persistence: ChatClientPersistence = {
  getItem(id) {
    const raw = localStorage.getItem(id)
    if (raw === null) return null
    const parsed: unknown = JSON.parse(raw)
    // A bare array is the legacy messages-only format, still accepted.
    if (Array.isArray(parsed)) return { messages: parsed }
    return isPersistedState(parsed) ? parsed : null
  },
  setItem(id, state) {
    localStorage.setItem(id, JSON.stringify(state))
  },
  removeItem(id) {
    localStorage.removeItem(id)
  },
}

Reads are best-effort: a getItem that throws or returns null is treated as "nothing stored", so an adapter that parses the wrong shape fails silently — the conversation just does not come back. Round-trip your adapter once against a real reload before shipping it.

Client and server are independent

Client persistence restores what one browser rendered. Server persistence (Chat persistence) keeps the authoritative copy for every user and survives a server restart. They compose: for the combination we recommend for most apps, and why, see the Persistence overview.