lukasa1993/tanstack-skills-extracted/tanstack-ai-core-client-persistence

Claude Code and agent skill by lukasa1993/tanstack-skills-extracted

Trust tier 3
  • community
Source check pending

Checking that the SKILL.md file is still in the repository.

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.

Install

npx vskill@latest install lukasa1993/tanstack-skills-extracted/tanstack-ai-core-client-persistence

SKILL.md

Client Persistence

Builds on ai-core, and on ai-core/chat-experience for useChat itself.
No extra package. The adapters below ship in the framework packages
(@tanstack/ai-react and 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-persistence and its ai-persistence/server skill.

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

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

Adapters

AdapterSurvivesNotes
localStoragePersistence()Reloads + browser restartsSync hydrate; quota-bound; JSON codec default
sessionStoragePersistence()Reloads in the same tabCleared when tab/session ends
indexedDBPersistence()Reloads + restartsAsync 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.

Mode A — cache everything (client-authoritative)

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

Mode B — server-authoritative (persistence: true)

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

What a reload restores

  1. Finished run — transcript from the adapter (mode A) or server (mode B).
  2. Paused on interrupt — approval UI restored (from the adapter in mode A,

the server hydrate in mode B).

  1. Still streaming — needs delivery durability on the route

(toServerSentEventsResponse(stream, { durability: … })) so the client can joinRun and finish the reply. Persistence alone is not enough.

Stable threadId is the identity

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

Generation hooks: server-driven only

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.

Turning it on (persistence: true)
tsx
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:

ts group=generation-persistence
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
},
})
}
  • Nothing is cached client-side. On mount the client hydrates the **last

generation** for its 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.

  • The server 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.

  • Best for multi-device / compliance (no generation metadata in browser

storage), exactly like chat's server-authoritative mode.

Restoring media: byte storage + artifactUrl

result comes back with its media only when the server persists the bytes (stores.artifacts + stores.blobs) AND withGenerationPersistence is given an artifactUrl mapper:

ts group=generation-persistence
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.

  • Nothing auto-runs from a hydrated record — generate(...) is always explicit.
  • Use 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).

Common mistakes

HIGH: No threadId

Record cannot be found after reload.

HIGH: Passing id to useChat

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

HIGH: persistence: true without server history

Empty chat after reload unless the server can reconstruct by threadId.

MEDIUM: Huge transcripts in localStorage

Quota and main-thread cost. Prefer persistence: true + server store, or IndexedDB with care.

MEDIUM: Expecting multi-device sync from client storage alone

localStorage is per-browser. Use server persistence for multi-device.

Cross-references

  • ai-persistence/server (@tanstack/ai-persistence) — authoritative server half
  • ai-core/chat-experience — useChat, resumable connections
  • Resumable streams docs — mid-stream rejoin

Security

  • Security scanPassed, 95 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

7-day trend score
0

Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/lukasa1993/tanstack-skills-extracted/tanstack-ai-core-client-persistence/badge)](https://verified-skill.com/skills/lukasa1993/tanstack-skills-extracted/tanstack-ai-core-client-persistence)

Something wrong with this skill or its page? Tell us.