Skip to content
Persistence

Persistence Overview

Persistence stores the conversation outside process memory. A reload, or the same user on another device, then loads that conversation.

The server saves each thread, and useChat loads it back when the page opens.

Quick setup

This setup keeps the chat on the server. It also lets a reply that was still streaming finish after a reload. It takes about five minutes.

1. Install the package

shell
npm i @tanstack/ai-persistence

The client half ships in the framework package you already use.

2. Create the persistence object

Make one object for the whole app. memoryPersistence() keeps everything in process memory, which is good for local development:

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

export const persistence = memoryPersistence()

3. Save and load on the server

Add withPersistence to your chat POST. Then add a GET on the same route:

ts
// api/chat.ts
import {
  chat,
  chatParamsFromRequest,
  memoryStream,
  resumeServerSentEventsResponse,
  toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { reconstructChat, 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-6.1-sol'),
    messages: params.messages,
    threadId: params.threadId,
    runId: params.runId,
    ...(params.resume ? { resume: params.resume } : {}),
    middleware: [withPersistence(persistence)],
  })
  return toServerSentEventsResponse(stream, {
    durability: { adapter: memoryStream(request) },
  })
}

export function GET(request: Request): Response | Promise<Response> {
  const durability = memoryStream(request)
  // A reply is still streaming: replay it from the log.
  if (durability.resumeFrom() !== null) {
    return resumeServerSentEventsResponse({ adapter: durability })
  }
  // Otherwise send back the stored thread.
  return reconstructChat(persistence, request, {
    // Without this check, anyone who guesses a thread id can read the thread.
    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.
}

4. Turn it on in the client

Give useChat a stable threadId and set persistence: true:

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

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

Send a message, then reload the page. The conversation comes back. Reload while a reply streams, and the reply finishes in place.

Tip

Your coding assistant can do this setup for you. After you install the package, run npx @tanstack/intent@latest install to add this package's Agent Skills. Then ask it to "add chat persistence to this app".

Keep the chat in the browser instead

If you do not want a server store, give useChat a browser storage adapter. You do not need steps 1, 2, or 3:

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

export function Chat() {
  const { messages } = useChat({
    threadId: 'support-chat',
    connection: fetchServerSentEvents('/api/chat'),
    persistence: localStoragePersistence(),
  })
  return <p>{messages.length} messages</p>
}

This keeps the chat on one device only. Client persistence compares the two modes and the storage adapters.

How it works

  • withPersistence saves the transcript, the run status, and any pending approvals to your stores.
  • persistence: true tells useChat that the server owns the history. On mount, useChat calls your GET with the threadId.
  • reconstructChat returns the stored messages. If a run is still streaming, it also returns that run. Then the client replays the run from the durability log.
  • A second device follows the same path, because the threadId is the only key.

Before you go to production, do these two things:

  1. Replace memoryPersistence() with an adapter for your database. See Build your own adapter.
  2. Replace memoryStream with a log that all your server processes share. See Resumable streams.

Which setup do I want?

You wantUse
The chat to survive a reload on one deviceA browser storage adapter
The same chat on another device, or after a server restartThe quick setup
A reload in the middle of a reply to finish the replyThe quick setup
A dropped connection to resume while the page stays openResumable Streams alone
A human approval that can wait for daysThe quick setup with an interrupts store. See Chat persistence
Sandbox files to come back after the provider sandbox is goneKeep Files After Reload

Generation and sandboxes

The same idea works outside chat:

When something surprises you, read How persistence works. It explains threads, runs, who owns the history, and the middleware lifecycle.