Appearance
Demo: AI Chat
Route: /demo/ai · Feature module: src/features/demo/ai/
What this demonstrates
Streaming an LLM response into the UI, and specifically resumable streams: if the connection drops mid-response (like when the user uses a phone and the connection is lost for a moment), the client reconnects and picks the exact same stream back up server-side instead of losing the reply or starting over. For this demo this does not mean the user can refresh the page, because every time the page a new ìd is generated for the chat, but the same technique can be used to persist a stream across page refreshes if you store the id in a database or localStorage.
Key files
| File | Role |
|---|---|
model/ai-chat.ts | AI_SYSTEM_PROMPT constant |
schema/ai-chat-schema.ts | chatRequestSchema - validates the /api/chat request body |
server/chat.ts | startChatStream(), resumeChatStream() - plain functions called by the route handlers below (raw streaming responses aren't tRPC-compatible, so this feature uses Route Handlers instead of a server/router.ts tRPC router) |
hooks/use-ai-chat.ts | Creates the Chat instance and wraps useChat |
components/ai-chat.tsx | Chat container - header bar, composes the pieces below |
components/chat-messages.tsx | Message list, empty/loading/error states |
components/chat-input.tsx | Textarea + send button |
src/app/api/chat/route.ts | POST - starts a new streamed response |
src/app/api/chat/[id]/stream/route.ts | GET - resumes an existing stream by id |
src/lib/ai.ts | OpenRouter provider client, AI_MODEL |
src/lib/stream-context.ts | Resumable-stream context (backed by Redis) |
src/app/(dashboard)/demo/ai/page.tsx | Page - missing-API-key handling |
How it works
The client creates a
Chatinstance (@ai-sdk/react) with aTextStreamChatTransportpointed at/api/chat, plus aprepareReconnectToStreamRequestpointed at/api/chat/${id}/stream:tsxnew Chat({ id: generateId(), transport: new TextStreamChatTransport({ api: "/api/chat", prepareReconnectToStreamRequest: ({ id }) => ({ api: `/api/chat/${id}/stream` }), }), })useChat({ chat, resume: true })- theresume: trueflag is what makes the hook automatically attempt to reconnect to an in-flight stream on mount, instead of only ever starting fresh ones.Sending a message
POSTs to/api/chat, which validates the body againstchatRequestSchemaand callsstartChatStream()(server/chat.ts), which in turn callsstreamText()from theaipackage against OpenRouter:tsconst result = streamText({ model: openrouter(AI_MODEL), system: AI_SYSTEM_PROMPT, messages: convertToModelMessages(messages) });Instead of returning
result.textStreamstraight to the client,startChatStream()wraps it:getStreamContext().createNewResumableStream(id, () => result.textStream).getStreamContext()is backed by theresumable-streampackage, which persists stream chunks (in Redis) keyed bychatIdas they're produced - not just buffered in the Node process, which would be lost on disconnect.If the client reconnects (page refresh mid-response, dropped connection),
useChat'sresume: truefires aGET /api/chat/[id]/stream, which callsresumeChatStream(id)(server/chat.ts), itself wrappingctx.resumeExistingStream(chatId). If the stream is still buffered server-side, playback continues from where it left off; if it already finished or never existed, the route returns204and the client just shows what it already has.
Try it
Open /demo/ai, send a message, and refresh the page while it's still streaming - the response keeps going instead of restarting or disappearing.
Requirements
OPENROUTER_API_KEY- without it, the page shows a warning card (checked server-side viaenv.OPENROUTER_API_KEY) instead of rendering the chat and failing at request time.REDIS_URLreachable - the resumable-stream backing store needs it, on top of the OpenRouter key.
Related docs
- Integrations - the
streamText/openrouterusage pattern outside this demo - Environment Variables -
OPENROUTER_API_KEY,AI_MODEL,REDIS_URL