Skip to content

AI SDK recipes

Follow and resume the same chat generation across tabs.

Open recipe

Resumable chat · React Router

Start with an existing React Router 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.

Terminal window
pnpm add spinetab ai@^7.0.116 zod@^4 @ai-sdk/react@^4.0.119

For strict AI SDK declarations, install these types and include node if your tsconfig restricts types:

Terminal window
pnpm add -D @types/json-schema @types/node

Keep your framework's existing dependencies. See compatible versions if upgrading an older 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.

vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { spinetab } from "spinetab/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [reactRouter(), spinetab()],
optimizeDeps: { entries: ["app/**/*.{ts,tsx}"] },
});

Save these files together in app/recipe/.

app/recipe/live.ts
import { createSpinetab } from "spinetab";
export const spinetab = createSpinetab({ anonymous: true });
app/recipe/transport.ts
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";
app/recipe/Recipe.tsx
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>
);
}
app/routes/queue.tsx
import Recipe from "../recipe/Recipe";
export default function QueuePage() {
return (
<main>
<Recipe />
</main>
);
}
app/routes.ts
import { type RouteConfig, route } from "@react-router/dev/routes";
export default [route("queue", "routes/queue.tsx")] satisfies RouteConfig;

Keep your existing routes and root Outlet. The dependency-scan entries include route imports on a cold first visit. Declarative/Data Mode apps use the Vite recipe instead.

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.

API options and advanced recovery · Authentication