Skip to main content
By default a TanStack AI ChatClient keeps messages in memory only, so they vanish on reload. TanStack AI exposes a tiny persistence interface - getItem / setItem / removeItem - and any backend that implements it becomes durable storage. Upstash Redis is a great fit: it’s serverless with a REST API (no connection pooling, works in any edge/serverless runtime), latency is low enough to write on every streamed token, and per-conversation keys with an optional TTL give you free expiry of stale chats.
This tutorial uses OpenAI for the model, but persistence is model-agnostic.

Prerequisites

  • An Upstash Redis database
  • A TanStack AI ChatClient (@tanstack/ai-client)
  • @upstash/redis
npm install @tanstack/ai-client @upstash/redis
UPSTASH_REDIS_REST_URL="https://..."
UPSTASH_REDIS_REST_TOKEN="..."

The adapter

A persistence adapter is just an object with three methods. Each may be sync or async — the client awaits them. We store the messages array under a namespaced key and revive createdAt (which becomes a string through JSON) on read.
// upstash-persistence.ts
import type { Redis } from "@upstash/redis";
import type { ChatClientPersistence, UIMessage } from "@tanstack/ai-client";

type UpstashPersistenceOptions = {
  redis: Redis;
  prefix?: string;
  ttlSeconds?: number;
};

export function upstashPersistence(options: UpstashPersistenceOptions): ChatClientPersistence {
  const { redis, prefix = "tanstack:chat:", ttlSeconds } = options;
  const key = (id: string) => `${prefix}${id}`;

  return {
    async getItem(id) {
      const stored = await redis.get<Array<UIMessage>>(key(id));
      if (!stored) return null;
      // createdAt round-trips as a string through JSON; revive it.
      return stored.map((m) => ({
        ...m,
        createdAt: typeof m.createdAt === "string" ? new Date(m.createdAt) : m.createdAt,
      }));
    },
    async setItem(id, messages) {
      await redis.set(key(id), messages, ttlSeconds ? { ex: ttlSeconds } : undefined);
    },
    async removeItem(id) {
      await redis.del(key(id));
    },
  };
}

Use it

Pass the adapter as persistence and give the client a stable id — that id is the storage key, so the same id loads the same conversation back.
import { Redis } from "@upstash/redis";
import { ChatClient } from "@tanstack/ai-client";
import { upstashPersistence } from "./upstash-persistence";

const redis = Redis.fromEnv();

const chat = new ChatClient({
  id: "conversation-123",
  connection,                                 // your OpenAI/SSE transport
  persistence: upstashPersistence({ redis }), // <- that's it
});

await chat.sendMessage("In one short sentence, what is Upstash Redis?");
The client now:
  • Hydrates on construction — calls getItem(id) and populates itself (overriding initialMessages).
  • Saves on every change — calls setItem(id, messages) on each new message and streamed chunk, through an ordered write queue.
  • Clears on clear() — calls removeItem(id).

Try it

Create a client, chat, then construct a brand-new client with the same id — it hydrates the full history from Redis with no initialMessages:
// Session 1 — persists to Redis
const a = new ChatClient({ id: "demo", connection, persistence: upstashPersistence({ redis }) });
await a.sendMessage("In one short sentence, what is Upstash Redis?");
await a.sendMessage("And in one sentence, what is TanStack?");

// Session 2 — same id, fresh client, no initialMessages
const b = new ChatClient({ id: "demo", connection, persistence: upstashPersistence({ redis }) });
await b.sendMessage("What did I ask you first? Quote it back.");
// -> 'You asked: "In one short sentence, what is Upstash Redis?"'
Expected behavior:
Session 1: 4 messages stored under "tanstack:chat:demo"
Session 2: hydrates all 4 from Redis, then answers with full context
clear():   key removed from Redis
The second client never saw the first one’s messages in memory - it recalled them from Redis, proving the conversation truly persisted.
Persistence is best-effort: TanStack AI swallows adapter errors so storage hiccups never break the chat. Handle errors inside the adapter if you need to react to them.

Next steps

  • Check out Agent Memory with Redis Search for more advanced retrieval.
  • Set ttlSeconds to auto-expire idle conversations.
  • Namespace keys per user, e.g. prefix: \chat:$:“.
  • Swap the same adapter shape onto any TanStack AI client (React/Vue/Solid/Svelte useChat).