AI SDK recipes
Follow and resume the same chat generation across tabs.
Resumable chat · Vite · React
Before you start
Section titled “Before you start”Start with an existing Vite · React app. Keep its renderer, routes and plugins. These files add one live view; Spinetab does not create your API.
Your backend returns AI SDK UI-message streams: POST /api/chat starts a generation and GET /api/chat/shared-chat/stream replays the active generation from its first start chunk. It echoes the supplied generation ID in x-generation-id or the start chunk's messageId. Return HTTP 204 when no generation is active.
Serve Cache-Control: no-store, no-transform. Your proxy must forward chunks promptly and close downstream responses on upstream failure. See the backend contract before using an ordinary chat route.
The examples use same-origin URLs and declare
anonymous: true: no token is supplied, although cookies
still flow. For a separate API origin, use its URL and configure CORS or
your application's proxy. For private data, add
credentials and user scopes;
never put a secret in these files.
1. Install
Section titled “1. Install”pnpm add spinetab ai@^7.0.116 zod@^4 @ai-sdk/react@^4.0.119For strict AI SDK declarations, install these types and include node if your tsconfig restricts types:
pnpm add -D @types/json-schema @types/nodeKeep your framework's existing dependencies. See compatible versions if upgrading an older app.
2. Configure your app
Section titled “2. Configure your app”Merge this addition into your configuration; preserve existing plugins and options. Restart the dev server afterwards. The plugin bundles the worker and discovers the adapters imported below.
import react from "@vitejs/plugin-react";import { spinetab } from "spinetab/vite";import { defineConfig } from "vite";
export default defineConfig({ plugins: [react(), spinetab()] });3. Connect and render
Section titled “3. Connect and render”Save these files together in src/recipe/.
import { createSpinetab } from "spinetab";
export const spinetab = createSpinetab({ anonymous: true });import { SpinetabChatTransport } from "spinetab/ai-sdk";import { spinetab } from "./live";
export const transport = new SpinetabChatTransport({ client: spinetab, api: "/api/chat",});// Both tabs must use the same authorised chat ID. Remount the view when it changes.export const chatId = "shared-chat";import { useChat } from "@ai-sdk/react";import { useEffect, useState } from "react";import { chatId, transport } from "./transport";
export default function Recipe() { const [mounted, setMounted] = useState(false); const { messages, status, error, sendMessage, resumeStream, stop } = useChat({ id: chatId, transport, }); useEffect(() => { setMounted(true); const unfollow = transport.follow(chatId, resumeStream); void resumeStream(); return () => { unfollow(); void stop(); }; }, [resumeStream, stop]); const busy = status === "submitted" || status === "streaming"; return ( <section aria-label="Chat"> {messages.map((message) => ( <p key={message.id}> {message.parts .map((part) => (part.type === "text" ? part.text : "")) .join("")} </p> ))} {error && <p role="alert">{error.message}</p>} <p role="status">{status}</p> <button type="button" disabled={!mounted || busy} onClick={() => { void sendMessage({ text: "Explain shared browser connections in one sentence.", }); }} > Send </button> </section> );}4. Mount the view
Section titled “4. Mount the view”import Recipe from "./recipe/Recipe";export default function App() { return <Recipe />;}Your existing entry point mounts App as usual. Keep its renderer plugin; no additional application provider is required beyond those shown.
Check the result
Section titled “Check the result”Open the same chat in two tabs and press Send in one. Both should display the same generation. A late tab explicitly resumes from the backend; it may need another HTTP response, but must not start another generation. Unmounting detaches this view and its follow observer, not the server's generation. Remount the component when the chat ID changes. Durable history and an explicit server-side stop endpoint belong to your app.