test-driven-development
OBobra/superpowers298k2,631 new stars in the last 7 days
Browser chat persistence on useChat / ChatClient: localStoragePersistence, sessionStoragePersistence, indexedDBPersistence. Client-authoritative (adapter, full transcript) vs server-authoritative (persistence: true, no client cache). Reload restore, pending interrupts, mid-stream rejoin with delivery durability. Use for SPA reload durability — NOT server history alone. Also covers generation hooks (useGenerateImage etc.), which take only the server-driven mode: persistence: true hydrates the last generation for the (REQUIRED) threadId from the server on mount and repaints status/result/error, nothing is cached in the browser. No extra package: the adapters ship in the framework packages.
Builds on ai-core, and on
ai-core/chat-experienceforuseChatitself.No extra package. The adapters below ship in the framework packages (
@tanstack/ai-reactand friends, re-exported from@tanstack/ai-client), so browser persistence needs nothing installed beyond what a chat UI already has. The server half is a separate package — see@tanstack/ai-persistenceand itsai-persistence/serverskill.
A ChatClient / useChat keeps messages in memory. The persistence option
stores one record per threadId so a reload can repaint the transcript,
restore a pending interrupt, and rejoin an in-flight run.
Import adapters from the framework package (not @tanstack/ai-client
unless vanilla JS):
import {
useChat,
fetchServerSentEvents,
localStoragePersistence,
sessionStoragePersistence,
indexedDBPersistence,
} from '@tanstack/ai-react'
| Adapter | Survives | Notes |
|---|---|---|
localStoragePersistence() | Reloads + browser restarts | Sync hydrate; quota-bound; JSON codec default |
sessionStoragePersistence() | Reloads in the same tab | Cleared when tab/session ends |
indexedDBPersistence() | Reloads + restarts | Async open (first paint may be empty briefly); structured clone |
All default to the chat persisted-state shape — no type argument or codec required for normal use.
import {
useChat,
fetchServerSentEvents,
localStoragePersistence,
} from '@tanstack/ai-react'
function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat', // stable — required
connection: fetchServerSentEvents('/api/chat'),
persistence: localStoragePersistence(),
})
// ...
}
Bare adapter ≡ full transcript + resume pointer. Browser owns history; server
(if any) mirrors when you post non-empty messages.
Best for: SPA, offline-first, single device, moderate conversation size.
persistence: true)import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
function Chat({ threadId }: { threadId: string }) {
const { messages, sendMessage } = useChat({
threadId,
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
})
// ...
}
Nothing is cached client-side: no transcript, no resume pointer.
On mount, useChat hydrates the thread from the server by threadId
(paint + tail active run). Same path for another device. Pair with server
withPersistence + a hydrate route (reconstructChat or equivalent).
Best for: large transcripts, multi-device, compliance (no message bodies in browser storage).
toServerSentEventsResponse(stream, { durability: … })) so the client can
joinRun and finish the reply. Persistence alone is not enough.threadId is the identityPersistence keys on threadId. The hooks have no separate id option — a
chat's identity is its threadId. Without a stable one, each load is a new
chat. Generate it server-side or from a route param the user owns; do not
randomize per mount.
The generation hooks (useGenerateImage, useGenerateVideo, useGeneration,
useSummarize, useTranscription, …) take a persistence option too, but it is
boolean only — there is no storage-adapter mode, and the browser caches
nothing. The hooks are transparent, mirroring useChat: a reload repaints the
hook's
normal fields — status ('idle' / 'generating' / 'success' /
'error'), error, and result — as if the run had just finished. There is
no resumeSnapshot, resumeState, pendingArtifacts, or resultArtifacts
field. The one extra field is runId: the id of the generation job currently
running, or null when nothing is in flight. The persisted record holds run
identity, status, error, and result metadata (ids, model, a provider video job
id), never the generated media bytes.
The hook return is exactly generate / result / isLoading / error /
status / stop / reset / runId.
persistence: true)import { useGenerateImage, fetchServerSentEvents } from '@tanstack/ai-react'
function ImageGenerator({ threadId }: { threadId: string }) {
const image = useGenerateImage({
threadId, // REQUIRED — the scope the last generation is hydrated under
connection: fetchServerSentEvents('/api/generate/image'),
persistence: true,
})
// After a reload: image.status / image.result / image.error are the last
// generation for `threadId`, fetched from the server — nothing was cached.
}
The server half — the same route handles the run and the hydration GET:
import {
generateImage,
generationParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import {
memoryPersistence,
reconstructGeneration,
withGenerationPersistence,
} from '@tanstack/ai-persistence'
// Needs `stores.generationRuns`; `memoryPersistence()` ships one.
const persistence = memoryPersistence()
export async function POST(request: Request) {
const { input, threadId } = await generationParamsFromRequest(
'image',
request,
)
if (typeof input.prompt !== 'string') {
throw new Error('This endpoint accepts text image prompts only.')
}
if (threadId === undefined) {
throw new Error('Generation persistence requires a `threadId`.')
}
return toServerSentEventsResponse(
generateImage({
adapter: openaiImage('gpt-image-2'),
prompt: input.prompt,
// The stable slot this run fills. Required by persistence: the run record
// is filed under it, and the client hydrates by it on mount.
threadId,
stream: true,
middleware: [withGenerationPersistence(persistence)],
}),
)
}
// Mount-time hydration: resolves `?runId=` (preferred) or the latest run linked
// to `?threadId=`, and returns `{ resumeSnapshot, activeRun }`.
export function GET(request: Request) {
return reconstructGeneration(persistence, request, {
// Multi-user routes MUST authorize: the ids come from the caller. Derive
// identity from server-side session state, then check ownership.
authorize: async (id, req) => {
// const user = await auth(req)
// return user != null && (await db.threadOwnedBy(user.id, id))
void id
void req
return true
},
})
}
threadId from the server via the connection's
hydrateGeneration handler (the SSE/HTTP adapters issue a GET with
?threadId= to the same endpoint URL) and repaints it into the normal fields.GET returns reconstructGeneration(persistence, request) from
@tanstack/ai-persistence — it resolves the run by ?runId= (preferred) or
the latest run linked to ?threadId=, and needs stores.generationRuns. Pair it with
withGenerationPersistence on the generation route. See
ai-core/media-generation and ai-persistence.artifactUrlresult comes back with its media only when the server persists the bytes
(stores.artifacts + stores.blobs) AND withGenerationPersistence is given an
artifactUrl mapper:
withGenerationPersistence(persistence, {
artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`,
})
artifactUrl stamps a durable app-origin URL onto each persisted ref and
rewrites the live result's media to it, so live and restored results match. The
durable refs travel on result.artifacts; on restore the hook rebuilds result
from them, so result.images[i].url (or a video's result.url) serves from your
own origin. result.artifacts is the whole artifact surface on the hook.
Without byte storage, a reload restores status / error and result stays
null.
Also worth knowing:
stop() marks the record no longer resumable; reset() clears the in-memory
snapshot.generate(...) is always explicit.status / result for a finished run; use runId to tell that a run was
still generating when the page closed, and to name it to your own server (to
cancel or poll the provider job — stop() only aborts the local stream).threadIdRecord cannot be found after reload.
id to useChatRemoved — threadId is the identity. (ChatClient still accepts id directly
as a lower-level escape hatch for keying storage separately from the wire
thread; the framework hooks do not.)
persistence: true without server historyEmpty chat after reload unless the server can reconstruct by threadId.
localStorageQuota and main-thread cost. Prefer persistence: true + server store, or
IndexedDB with care.
localStorage is per-browser. Use server persistence for multi-device.
@tanstack/ai-persistence) — authoritative server halfuseChat, resumable connectionsOBobra/superpowers298k2,631 new stars in the last 7 days
MAmattpocock/skills288k10,178 new stars in the last 7 days
AFaffaan-m/ECC278k4,046 new stars in the last 7 days
Audit accessibility on Web, iOS, and Android to meet WCAG 2.2, with keyboard navigation and screen-reader support.
AFaffaan-m/ECC278k4,046 new stars in the last 7 days
AFaffaan-m/ECC278k4,046 new stars in the last 7 days
FIfirecrawl/firecrawl191k2,010 new stars in the last 7 days