|
| 1 | +--- |
| 2 | +title: TanStack AI Chat Persistance |
| 3 | +description: Use Upstash Redis to persist TanStack AI chat histories across reloads, navigation, and devices with a simple adapter. |
| 4 | +--- |
| 5 | + |
| 6 | +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. |
| 7 | + |
| 8 | +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. |
| 9 | + |
| 10 | +<Note>This tutorial uses OpenAI for the model, but persistence is model-agnostic.</Note> |
| 11 | + |
| 12 | +## Prerequisites |
| 13 | + |
| 14 | +- An [Upstash Redis](https://console.upstash.com) database |
| 15 | +- A TanStack AI `ChatClient` (`@tanstack/ai-client`) |
| 16 | +- `@upstash/redis` |
| 17 | + |
| 18 | +```bash |
| 19 | +npm install @tanstack/ai-client @upstash/redis |
| 20 | +``` |
| 21 | + |
| 22 | +```bash |
| 23 | +UPSTASH_REDIS_REST_URL="https://..." |
| 24 | +UPSTASH_REDIS_REST_TOKEN="..." |
| 25 | +``` |
| 26 | + |
| 27 | +## The adapter |
| 28 | + |
| 29 | +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. |
| 30 | + |
| 31 | +```typescript |
| 32 | +// upstash-persistence.ts |
| 33 | +import type { Redis } from "@upstash/redis"; |
| 34 | +import type { ChatClientPersistence, UIMessage } from "@tanstack/ai-client"; |
| 35 | + |
| 36 | +export function upstashPersistence( |
| 37 | + redis: Redis, |
| 38 | + { prefix = "tanstack:chat:", ttlSeconds }: { prefix?: string; ttlSeconds?: number } = {}, |
| 39 | +): ChatClientPersistence { |
| 40 | + const key = (id: string) => `${prefix}${id}`; |
| 41 | + |
| 42 | + return { |
| 43 | + async getItem(id) { |
| 44 | + const stored = await redis.get<Array<UIMessage>>(key(id)); |
| 45 | + if (!stored) return null; |
| 46 | + // createdAt round-trips as a string through JSON; revive it. |
| 47 | + return stored.map((m) => ({ |
| 48 | + ...m, |
| 49 | + createdAt: typeof m.createdAt === "string" ? new Date(m.createdAt) : m.createdAt, |
| 50 | + })); |
| 51 | + }, |
| 52 | + async setItem(id, messages) { |
| 53 | + await redis.set(key(id), messages, ttlSeconds ? { ex: ttlSeconds } : undefined); |
| 54 | + }, |
| 55 | + async removeItem(id) { |
| 56 | + await redis.del(key(id)); |
| 57 | + }, |
| 58 | + }; |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +## Use it |
| 63 | + |
| 64 | +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. |
| 65 | + |
| 66 | +```typescript |
| 67 | +import { Redis } from "@upstash/redis"; |
| 68 | +import { ChatClient } from "@tanstack/ai-client"; |
| 69 | +import { upstashPersistence } from "./upstash-persistence"; |
| 70 | + |
| 71 | +const redis = Redis.fromEnv(); |
| 72 | + |
| 73 | +const chat = new ChatClient({ |
| 74 | + id: "conversation-123", |
| 75 | + connection, // your OpenAI/SSE transport |
| 76 | + persistence: upstashPersistence(redis), // <- that's it |
| 77 | +}); |
| 78 | + |
| 79 | +await chat.sendMessage("In one short sentence, what is Upstash Redis?"); |
| 80 | +``` |
| 81 | + |
| 82 | +The client now: |
| 83 | + |
| 84 | +- **Hydrates on construction** — calls `getItem(id)` and populates itself (overriding `initialMessages`). |
| 85 | +- **Saves on every change** — calls `setItem(id, messages)` on each new message and streamed chunk, through an ordered write queue. |
| 86 | +- **Clears on `clear()`** — calls `removeItem(id)`. |
| 87 | + |
| 88 | +## Try it |
| 89 | + |
| 90 | +Create a client, chat, then construct a **brand-new** client with the same `id` — it hydrates the full history from Redis with no `initialMessages`: |
| 91 | + |
| 92 | +```typescript |
| 93 | +// Session 1 — persists to Redis |
| 94 | +const a = new ChatClient({ id: "demo", connection, persistence: upstashPersistence(redis) }); |
| 95 | +await a.sendMessage("In one short sentence, what is Upstash Redis?"); |
| 96 | +await a.sendMessage("And in one sentence, what is TanStack?"); |
| 97 | + |
| 98 | +// Session 2 — same id, fresh client, no initialMessages |
| 99 | +const b = new ChatClient({ id: "demo", connection, persistence: upstashPersistence(redis) }); |
| 100 | +await b.sendMessage("What did I ask you first? Quote it back."); |
| 101 | +// -> 'You asked: "In one short sentence, what is Upstash Redis?"' |
| 102 | +``` |
| 103 | + |
| 104 | +Expected behavior: |
| 105 | + |
| 106 | +``` |
| 107 | +Session 1: 4 messages stored under "tanstack:chat:demo" |
| 108 | +Session 2: hydrates all 4 from Redis, then answers with full context |
| 109 | +clear(): key removed from Redis |
| 110 | +``` |
| 111 | + |
| 112 | +The second client never saw the first one's messages in memory - it recalled them from Redis, proving the conversation truly persisted. |
| 113 | + |
| 114 | +<Note>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.</Note> |
| 115 | + |
| 116 | +## Next steps |
| 117 | + |
| 118 | +- Check out [Agent Memory with Redis Search](/redis/tutorials/agent_memory) for more advanced retrieval. |
| 119 | +- Set `ttlSeconds` to auto-expire idle conversations. |
| 120 | +- Namespace keys per user, e.g. `prefix: \`chat:${userId}:\``. |
| 121 | +- Swap the same adapter shape onto any TanStack AI client (React/Vue/Solid/Svelte `useChat`). |
0 commit comments