The vercel ai sdk usechat hook removes the boilerplate of building a streaming chat UI in React, but its conventions around message shape and transport deserve scrutiny before you ship. This walkthrough builds a Next.js App Router chat app from scratch, covers the server route, client wiring, state persistence, and error boundaries, then shows how to repoint the model backend without rewriting your frontend.
Dependencies and project setup
Install the SDK and the provider package. In a Next.js 14+ App Router project:
npm install ai @ai-sdk/openai @ai-sdk/react zod
Use React 18+. The ai package provides streamText and response helpers; @ai-sdk/react exports useChat. Avoid mixing major versions—the streaming protocol changed between 3.x and 4.x, and a mismatch produces silent parse failures on the client.
Server route: /api/chat
Create app/api/chat/route.ts. The vercel ai sdk usechat hook POSTs an array of { role, content } messages to this endpoint. Return a data stream response:
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
export const runtime = 'edge';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
messages,
});
return result.toDataStreamResponse();
}
Set runtime = 'edge' only if your provider allows edge fetch; otherwise use Node. The toDataStreamResponse() method emits the exact format useChat expects. If you return plain JSON, the hook will not stream and will throw on parse.
Validating input
Don’t trust the client. Wrap with zod:
import { z } from 'zod';
const schema = z.object({
messages: z.array(z.object({ role: z.string(), content: z.string() })),
});
const { messages } = schema.parse(await req.json());
This catches malformed payloads before they hit the model and prevents expensive calls.
Client wiring with useChat
In a client component:
'use client';
import { useChat } from '@ai-sdk/react';
export function Chat() {
const { messages, input, handleInputChange, handleSubmit, error, isLoading } = useChat();
return (
<div>
{messages.map(m => (
<div key={m.id}>
<strong>{m.role}</strong>: {m.content}
</div>
))}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
<button type="submit" disabled={isLoading}>Send</button>
</form>
{error && <p style={{ color: 'red' }}>{error.message}</p>}
</div>
);
}
The vercel ai sdk usechat hook manages messages state, input binding, and the fetch call. It appends the user message optimistically and updates on stream chunks. Note that messages items carry an id generated client-side; preserve it if you persist.
Streaming, tools, and partial output
Streaming works out of the box, but tool calls require explicit handling. If your model returns a tool invocation, useChat stores it in message.toolInvocations. Render them or process on the client:
{messages.map(m => (
<div key={m.id}>
{m.toolInvocations?.map(t => <pre>{JSON.stringify(t)}</pre>)}
<span>{m.content}</span>
</div>
))}
The hook does not auto-execute tools; you must call your function and send results back via append with a tool_result role if needed. Tradeoff: keeping tools server-side simplifies the client but loses interactivity. Partial text arrives as content chunks—no manual concatenation required.
Conversation state and persistence
useChat keeps messages in React state only. On reload, the chat resets. To persist, use the id and initialMessages props:
const { messages, append } = useChat({
id: 'session-123',
initialMessages: await loadMessages('session-123'),
});
For server persistence, save on each onFinish callback:
useChat({
onFinish: async (message) => {
await fetch('/api/save', { method: 'POST', body: JSON.stringify(message) });
},
});
Beware: onFinish fires per assistant message, not per conversation turn. Batch writes if you expect high volume. A minimal save route:
// app/api/save/route.ts
export async function POST(req: Request) {
const msg = await req.json();
// insert msg into your store keyed by msg.id
return new Response('ok');
}
Error handling and retries
Network failures surface as error. The vercel ai sdk usechat hook does not auto-retry. Implement a manual retry by calling regenerate:
<button onClick={() => regenerate()}>Retry</button>
If the server returns a non-200, the stream aborts and error is set. Wrap the submit handler to catch validation issues. On the server, return structured errors:
return result.toDataStreamResponse({
getErrorMessage: (e) => 'Model timeout',
});
This surfaces a clean string to the client error.message. Without getErrorMessage, the client receives a generic abort error.
Swapping providers and custom endpoints
The default openai import pins you to one vendor. To use a gateway or self-hosted model, change the base URL:
import { createOpenAI } from '@ai-sdk/openai';
const gateway = createOpenAI({
baseURL: 'https://api.n4n.ai/v1',
apiKey: process.env.GATEWAY_KEY,
});
const result = streamText({
model: gateway('anthropic/claude-3.5-sonnet'),
messages,
});
n4n.ai exposes one OpenAI-compatible endpoint that fronts 240+ models and applies automatic fallback when a provider is degraded, so the same useChat frontend works without branching logic. Honor client routing directives by passing model strings through. The hook is agnostic to the backend as long as the response shape matches the AI SDK data stream spec.
Keeping keys server-side
Never expose provider keys to the client. The route runs on the server; the hook only talks to your /api/chat. If you must call a provider directly from the browser, use a signed token proxy.
Common pitfalls and tradeoffs
- Message shape drift: The hook expects
roleto beuser,assistant, orsystem. Custom roles break streaming. - Edge runtime limits: Some providers block edge fetches; you’ll see opaque timeouts. Use Node runtime if unsure.
- Hydration mismatch: Passing
initialMessagesfrom server components requires matching IDs. Generate stable IDs withcrypto.randomUUID(). - Over-fetching: Each
appendsends the full history. For long conversations, implement sliding window truncation server-side. - No built-in rate limit: The hook will happily spam your API on rapid clicks. Debounce or disable input while
isLoading. - Experimental options churn:
experimental_prepareRequestBodyand similar flags change across minor versions. Pin the SDK version in production.
The vercel ai sdk usechat hook is a thin layer, not a state machine. For multi-step agent loops, you’ll outgrow it and should orchestrate streams manually with readStreamableValue.
When to skip useChat
If you need optimistic UI with rollback, custom transport (WebSocket), or non-chat completions, write your own fetch + ReadableStream parser. The protocol is documented; reimplementing it is ~80 lines. The hook saves time until it doesn’t—at which point the abstraction leaks and you’ll want direct control over the stream lifecycle.