adding ai chat to your frontend, the easy way
Every "add AI to your app" tutorial I found started the same way: pick a provider, learn their SDK, write code you can never move anywhere else. I kept not doing it out of spite, honestly. Then the TanStack folks, the Query and Router people, shipped TanStack AI, and it is the first one built around the opposite idea. One typed API. Any provider. Swap the model without rewriting the call.
It hit 1.0 recently, so fair warning, some of this is still settling. But the core idea is solid, and I got a streaming chat working in an afternoon. Here is the whole thing.
The mental model: two halves
A chat app with TanStack AI is two pieces. The server calls chat() with a provider adapter and returns the stream as server-sent events. The client uses a useChat hook that posts your messages and renders the tokens as they arrive. Server streams, client renders. That is genuinely all of it.
1. Install three packages
The core, the React bindings, and one provider adapter. I went with OpenRouter because it gives you 300+ models behind one API key, which is the perfect "I just want to try this" setup:
npm install @tanstack/ai @tanstack/ai-react @tanstack/ai-openrouter
Put your key in the server environment, a .env file with OPENROUTER_API_KEY. It stays on the server. The browser never sees it.
2. The server route
One API route. It reads the messages from the request, calls the model through the adapter, and streams the response back:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { createOpenRouterText } from '@tanstack/ai-openrouter'
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const stream = chat({
adapter: createOpenRouterText(
'openai/gpt-5.5',
process.env.OPENROUTER_API_KEY!,
),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
})
return toServerSentEventsResponse(stream)
}3. The chat UI
The client is a useChat hook pointed at your route. Messages arrive as parts, so you render the text parts and ignore the rest for now (tool calls show up as other part types later):
import { useState } from 'react'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
export function ChatBox() {
const [input, setInput] = useState('')
const { messages, sendMessage, isLoading, stop } = useChat({
connection: fetchServerSentEvents('/api/chat'),
threadId: 'my-chat',
})
return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, i) =>
part.type === 'text' ? (
<p key={i}>{part.content}</p>
) : null,
)}
</div>
))}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask something..."
/>
{isLoading ? (
<button onClick={stop}>Stop</button>
) : (
<button
onClick={() => {
sendMessage(input)
setInput('')
}}
>
Send
</button>
)}
</div>
)
}Type a message, hit send, and the answer streams in word by word while the model is still writing it. The stop button cancels mid-stream. It feels like magic the first time and it is about 60 lines.
The part I actually love
Switching providers is changing the adapter. That is it. Your server route, your UI, your message handling, all untouched. OpenAI today, Claude tomorrow, a local Ollama model when you are feeling spicy. The code does not care.
So, what have I learnt?
- Streaming is not scary once you see it is just server-sent events with a nice wrapper.
- Provider lock-in is a choice, and now it is an easy choice to avoid.
- Type safety across the client/server boundary is the real feature. The types for messages, parts, and tool calls just work.
Next on my list: tools, where the model can call your functions. TanStack AI has toolDefinition() for that. Define once, run on the server or the client. But that is another post.
If you build the chat box, start with OpenRouter. Thank me later.