Basic Setup
Before we get to anything fancy, we need to get the basics working: a server route that can talk to an AI model, and a chat panel on the page that can show what the model says back. That is what this section is about.
How TanStack AI works
TanStack AI is built around streaming. When you ask the model a question, the answer arrives one small piece at a time, not all at once. The full path looks like this:
useChat (in the browser) ─┐
├──→ HTTP POST ──→ server route ──→ chat({ adapter, messages }) ──→ Server-Sent Events (SSE) ──→ back to useChat
fetchServerSentEventsOn the server side, a working chat route only needs a few lines:
import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai'
import { openRouterText } from '@tanstack/ai-openrouter'
const adapter = openRouterText('nvidia/nemotron-3-super-120b-a12b:free')
export const action = async ({ request }: Route.ActionArgs) => {
const { messages } = await chatParamsFromRequest(request)
const stream = chat({ adapter, messages })
return toServerSentEventsResponse(stream)
}
Four things to notice here:
- The adapter already knows which model to use. When you call
openRouterText('nvidia/nemotron-3-super-120b-a12b:free')you get back an adapter that is locked to that model. Thechat()call does not take a separatemodelfield. - The API key is read from
process.env.OPENROUTER_API_KEYautomatically. You do not pass it in. chat()returns a stream of small pieces, not a finished response.toServerSentEventsResponse(stream)turns that stream into something the browser can read. It uses Server-Sent Events (SSE), which is a simple way for the server to push data to the browser over time.
The OpenRouter adapter is just one option. There are similar adapters for OpenAI, Anthropic, Gemini, Ollama, and others. Switching between them later is a one-line change.
Why we check user input
If the browser sends
{ messages: [] } or { foo: 'bar' }, you do not want the route to crash or to forward a broken payload to the model. TanStack AI ships a helper for exactly this: chatParamsFromRequest(request).It reads the request body, validates it as an AG-UI envelope (the wire format
useChat POSTs), and returns spread-friendly params you can hand straight to chat():const { messages } = await chatParamsFromRequest(request)
A few notes on what it does:
- On a malformed body,
chatParamsFromRequestthrows aResponsewith status 400 and a clear error message. React Router 7 surfaces thrownResponseobjects to the client automatically, so you do not need atry/catch. - The returned
messagescome back typed asArray<UIMessage | ModelMessage>— exactly the shapechat()accepts. - It also returns
forwardedProps,threadId,runId, and other AG-UI envelope fields when you need them.
In the first two exercises you only need the
messages field, so the route stays one line. From Exercise 3 onwards you'll start using forwardedProps to pass page-specific data (like the current product id) — and you'll add a small Zod schema (PageForwardedProps) to validate just that portion.How the browser side connects
On the browser, one hook does all the chat work.
useChat gives you back a messages list, a sendMessage function, and an isLoading flag. You do not manage the connection or the message list yourself.const { messages, sendMessage, isLoading } = useChat({
connection: fetchServerSentEvents('/api/chat'),
})
The
messages you get back are a bit richer than the { role, content } pairs you send to the server. Each message is a UIMessage that looks like this:interface UIMessage {
id: string
role: 'system' | 'user' | 'assistant'
parts: Array<MessagePart>
}
Each message is made of
parts. A part can be text, thinking, tool-call, tool-result, an image, audio, video, or a document. For now you will only see text parts. We will handle the others as they become useful in later sections.What you will build in this section
Three short exercises:
- Wire up the chat route — replace a
501 TODOplaceholder inapp/routes/api.chat.tsxwith a realchat()call that checks the input, talks to the model through OpenRouter, and sends a Server-Sent Events (SSE) stream back to the browser. - Show the messages on the page — swap the panel's hand-written
fetchcall foruseChatso the UI shows real chat bubbles, remembers the conversation, and streams answers as they come in. - Configure the agent — give the bot a clear role with a global system prompt that refuses off-topic questions, feed it the real product the customer is looking at by fetching it on the server, and turn on the model's reasoning mode with the
reasoningoption so it thinks before it answers.
By the end of this section, the floating panel on every product page will be a focused EpicStore shopping assistant that knows which product the customer is viewing and shows its reasoning above each answer. The next section adds tools so the bot can also look up other products from the catalog.
Let's go! 🚀