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