Skip to content
Persistence

Client Persistence

Client persistence writes useChat messages to storage in the browser. A reload keeps the conversation and a reply that was still streaming.

On reload, the persistence option also brings back a pending approval.

Quick setup

Pick who owns the history. Then copy that path.

The browser owns the chat

Use this for a single-page app, an offline-first app, or any app with no server store. Pass a storage adapter and a stable threadId:

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

export function Chat() {
  const { messages, sendMessage } = useChat({
    threadId: 'support-chat',
    connection: fetchServerSentEvents('/api/chat'),
    persistence: localStoragePersistence(),
  })
  return (
    <div>
      {messages.map((message) => (
        <div key={message.id}>{message.role}</div>
      ))}
      <button type="button" onClick={() => void sendMessage('hi')}>
        Send
      </button>
    </div>
  )
}

localStoragePersistence() needs no type argument and no codec. It uses the chat record shape and a JSON codec by default.

Send a message and reload. The conversation is there, with no network call.

The server owns the chat

Use this for large transcripts, for the same chat on more than one device, or to keep message content out of the browser. You need Chat persistence on the server first.

  1. Set persistence: true in the client:

    tsx
    import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
    
    export function Chat({ threadId }: { threadId: string }) {
      const { messages, isHydrating, sendMessage } = useChat({
        threadId,
        connection: fetchServerSentEvents('/api/chat'),
        persistence: true,
      })
      if (isHydrating) return <p>Loading conversation...</p>
      return (
        <div>
          {messages.map((message) => (
            <div key={message.id}>{message.role}</div>
          ))}
          <button type="button" onClick={() => void sendMessage('hi')}>
            Send
          </button>
        </div>
      )
    }
  2. Add a GET next to your chat POST:

    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 sends a resume cursor. Replay the log.
      if (durability.resumeFrom() !== null) {
        return resumeServerSentEventsResponse({ adapter: durability })
      }
      // Otherwise return the stored thread, plus any run that still streams.
      return reconstructChat(persistence, request, {
        // Without this, anyone who guesses a thread id gets the 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.
    }

The replay branch works only when your POST records the stream. To record it, pass { durability: { adapter: memoryStream(request) } } to toServerSentEventsResponse in the POST. The overview quick setup shows the full route.

Reload the page. useChat loads the thread from the server. Open the same threadId on another device, and you get the same chat.

You do not fetch or seed the transcript yourself. You need no loader and no initialMessages. The threadId is the stable key, and the server finds everything from it.

Pick a stable thread id

The threadId is the key that the record is saved under. A new id on each mount restores nothing. Take it from your own data, for example a conversation id or a route param. Id map explains how to choose one, and how it differs from the runId that the hook reports.

What a reload restores

  • The transcript. It shows again from storage or from the server.
  • A pending interrupt. An approval prompt comes back as it was.
  • A reply that was still streaming. It finishes in place. This needs a durability-backed connection: a route that records the stream and has a replay handler. See Resumable streams.

Replay rebuilds reasoning, tool activity, and text from the durable log. This includes runs that emit reasoning before their first tool or text event. The completed activity stays in the transcript that the next message sends.

Choose a mode

persistence takes a storage adapter or a boolean:

ValueWho owns the historyWhat the client cachesUse it for
A storage adapterThe clientThe transcript and a resume pointerSingle-page and offline apps, one device, small to medium history
trueThe serverNothingLarge histories, many devices, no transcripts in the browser
false or omittedNobodyNothingA chat that can start empty after a reload

The generation hooks take the same option, but as a boolean only. See Generation persistence.

Client and server persistence are independent:

  • Client persistence restores what one browser showed.
  • Server persistence (Chat persistence) keeps the true copy for every user, and survives a server restart.

For the setup that most apps use, see Which setup do I want?.

Choose a storage backend

Three adapters ship in @tanstack/ai-client. Every framework package exports them again. They all have the same shape:

AdapterHow long it keeps dataNotesUse it for
localStoragePersistenceAcross reloads and browser restartsSynchronous, about 5MB quota, JSON codecThe default: keep a conversation for next time
sessionStoragePersistenceOne tab, until the tab closesSame shape as localStorageA chat that must not outlive the tab
indexedDBPersistenceAcross reloads and restartsAsync, structured clone (a Date comes back exactly), room for large dataBig transcripts, or values that a JSON codec changes
tsx
import { indexedDBPersistence } from '@tanstack/ai-react'

const persistence = indexedDBPersistence()

Each adapter throws only when you use it and its browser store is missing, for example during server-side rendering. So you can create one on the server safely.

Advanced

Page a long thread

With persistence: true, history: { pageSize } loads only the newest messages on hydrate. Call loadOlderMessages() for more:

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

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

function Chat({ threadId }: { threadId: string }) {
  const {
    messages,
    isHydrating,
    hasOlderMessages,
    loadOlderMessages,
    sendMessage,
  } = useChat({
    threadId,
    connection,
    persistence: true,
    history: { pageSize: 50 },
  })
  if (isHydrating) return <p>Loading conversation...</p>
  return (
    <div>
      {hasOlderMessages ? (
        <button
          type="button"
          onClick={() => {
            void loadOlderMessages().catch(() => {
              // Show a retry. The messages on screen stay.
            })
          }}
        >
          Load older
        </button>
      ) : null}
      {messages.map((m) => (
        <div key={m.id}>{m.role}</div>
      ))}
      <button type="button" onClick={() => void sendMessage('hi')}>
        Send
      </button>
    </div>
  )
}

The library does not watch the scrollbar. Call loadOlderMessages() from your own scroll handler.

  • history is valid only with persistence: true. Without history, hydrate loads the full thread.
  • hasOlderMessages follows page.truncated from the last hydrate or older-page response.
  • With history.pageSize, a send posts only the new turn. A reload posts the last user message, because the old assistant message is already gone in the client. A resume posts from that user message through the assistant message on screen, so the stored tool call stays.
  • If loadOlderMessages() fails, the promise rejects, the messages on screen stay, and hasOlderMessages stays true. Catch the rejection in your UI.

Loading state

isHydrating is true from the first render until the transcript is in place.

  • If a run still generates, isLoading is already true when isHydrating turns false.
  • If the load fails, isHydrating turns false and error is set.

When the transcript appears depends on the adapter and the framework:

  • A direct ChatClient and most framework wrappers load sync adapters (localStorage, sessionStorage) during construction.
  • React is different. The first committed render uses initialMessages and initialResumeSnapshot, and browser persistence applies after the hook attaches. This keeps the server snapshot stable during SSR hydration.
  • IndexedDB always loads asynchronously, after the database opens.

What reconstructChat returns

reconstructChat returns a JSON body with these fields:

  • messages: the transcript as UI messages.
  • activeRun: { runId } when a run still generates for the thread, else null. The client follows it through the replay branch of the GET.
  • interrupts: the pending interrupts and the run they paused, else null. Needs an interrupts store.
  • page: { truncated, cursor }, only when the request has a valid limit query param. reconstructChat reads limit and before from the query.

You never handle a run id. A second device resumes the live run the same way the first tab does.

Show how long each turn took

A live turn can time itself on the client. After a reload, that time is gone. Pass includeRuns: true to get it back from the server:

ts
import { reconstructChat } from '@tanstack/ai-persistence'
import { persistence } from './persistence'

export function GET(request: Request): Promise<Response> {
  return reconstructChat(persistence, request, { includeRuns: true })
}

Each assistant message of a finished run then has the run timings, in epoch ms, on metadata.tanstack.run:

tsx
import type { UIMessage } from '@tanstack/ai'

function TurnDuration({ message }: { message: UIMessage }) {
  const run = message.metadata?.tanstack?.run
  if (!run?.finishedAt) return null
  const seconds = Math.round((run.finishedAt - run.startedAt) / 1000)
  return <span>Worked for {seconds}s</span>
}

The response also has a top-level runs list with runId, status, startedAt, and finishedAt for each finished run. The timings need:

  • A runs store that implements listByThread.
  • Messages that withPersistence saved with this version or later.

AG-UI activity rows

AG-UI activity events show as role: 'activity' rows in messages.

  • A client storage adapter keeps each row with the same id and content.
  • persistence: true restores the rows from the server only when the server has an activities store. See AG-UI activity rows on reload.

The next sendMessage does not send activity rows as model input.

Handle restored client tools

A live client tool runs when its call arrives from the stream. Hydration restores its pending execution, but it does not run the browser code again, because that work is not always safe to repeat. The execution stays an internal interrupt, and it is not in interrupts.

Use onInterruptStateChange to tell restored interrupt state from live updates. By default, a restored batch stays pending. This example cancels every restored batch:

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

function Chat() {
  const [restoredBatch, setRestoredBatch] = useState(false)
  const { cancelInterrupts } = useChat({
    threadId: 'support-chat',
    connection: fetchServerSentEvents('/api/chat'),
    persistence: localStoragePersistence(),
    onInterruptStateChange(_state, { source }) {
      setRestoredBatch(source === 'hydrate')
    },
  })

  useEffect(() => {
    if (restoredBatch) cancelInterrupts()
  }, [cancelInterrupts, restoredBatch])

  return null
}
  • source is hydrate for state from an initial resume snapshot, a client storage adapter, or server hydration.
  • source is live for streamed changes and changes that the client starts.

cancelInterrupts() cancels the full internal batch. That includes visible approvals and hidden client-tool executions. It cannot cancel only one kind of interrupt.

Write your own storage adapter

Any object with getItem, setItem, and removeItem works. The record is one { messages, resume? } object per chat id: the transcript, plus the pointer that lets a reload rejoin a running reply. So setItem gets that full 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)
    // Wrap a bare message array saved by a 0.x app.
    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)
  },
}

Warning

Reads are best effort. If getItem throws or returns null, the client treats it as "nothing stored". So an adapter that parses the wrong shape fails silently, and the conversation does not come back. Do one real reload with your adapter before you ship it.