Vercel AI SDK with Next.js

Streaming a model response is an interaction between a server route, an HTTP response, and a client that can render a message while it is still arriving. The Vercel AI SDK takes care of much of that protocol work, but it does not decide what the application is allowed to send, store, or show.
This walkthrough keeps those decisions visible. One Next.js Route Handler owns the provider call, one client component owns the input and display state, and a schema handles data that the rest of the application needs to trust.
Current compatibility — checked August 30, 2026: This post dates to June 12, 2025. Its original snippets target AI SDK 4:
ai/react, the internally manageduseChatinput state,toDataStreamResponse(), andstreamObject(). Those snippets are an archive. The official documentation checked for this revision describes AI SDK 6 as the stable line, with a transport-baseduseChatAPI andtoUIMessageStreamResponse(). The examples below useai@^6.0.0,@ai-sdk/react@^3.0.0, and@ai-sdk/openai@^3.0.0. Keep those package majors together and let your lockfile choose the patch versions. If you are testing the AI SDK 7 pre-release line, follow its versioned migration guide instead of mixing v6 and v7 examples. The AI SDK 6 migration guide explains the package-major relationship and the breaking API changes.
Install a compatible major baseline
Create a Next.js App Router project:
pnpm create next-app@latest my-ai-app --typescript --app
cd my-ai-appInstall the AI SDK, its React UI package, the OpenAI provider, and Zod for schema validation:
pnpm add ai@^6.0.0 @ai-sdk/react@^3.0.0 @ai-sdk/openai@^3.0.0 zodPut the provider key in .env.local:
# .env.local
OPENAI_API_KEY=sk-your-keyThe provider reads OPENAI_API_KEY on the server. Do not rename it to a
NEXT_PUBLIC_ variable: anything with that prefix can be included in browser
code. Configure the same server-only variable in the deployment environment for
preview and production.
Keep the model call on the server
Next.js Route Handlers live in route.ts files under app. The handler below
accepts the message history sent by useChat, converts UI messages to the model
format expected by streamText, and returns the SDK's UI message stream.
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai'
import {
convertToModelMessages,
safeValidateUIMessages,
streamText,
type UIMessage,
} from 'ai'
type ChatRequest = {
messages?: unknown
}
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as ChatRequest | null
if (!body || !Array.isArray(body.messages)) {
return Response.json(
{ error: 'Request body must include a messages array' },
{ status: 400 },
)
}
const validation = await safeValidateUIMessages({
messages: body.messages as UIMessage[],
})
if (!validation.success) {
console.error('Invalid UI messages', validation.error)
return Response.json({ error: 'Invalid chat messages' }, { status: 400 })
}
try {
const result = streamText({
model: openai('gpt-5.1'),
system: 'You are a concise technical assistant.',
messages: await convertToModelMessages(validation.data),
})
return result.toUIMessageStreamResponse({
onError: (error) => {
console.error('AI stream failed', error)
return 'The model request failed. Try again.'
},
})
} catch (error) {
console.error('AI request could not start', error)
return Response.json(
{ error: 'Unable to start the model request' },
{ status: 502 },
)
}
}There are two useful boundaries in this short route. The provider key never enters
the browser, and UIMessage[] is converted before it reaches the language model.
In AI SDK 6, convertToModelMessages is asynchronous because tool output can
also require asynchronous conversion.
The route uses safeValidateUIMessages before conversion. It checks the UI
message shape and reports failure as a structured result rather than throwing, so
malformed client data gets a 400 response instead of reaching the model. The
throwing validateUIMessages variant accepts the same schemas if that control
flow fits better. If the application later accepts tools, metadata, or custom
data parts, pass the corresponding definitions to the validator. Also load the
authoritative conversation history on the server when authorization or privacy
depends on it; a browser should not be able to grant itself access by posting a
different history.
The handler uses the default Next.js runtime. An Edge deployment can be a good fit for some streaming workloads, but choose it only after checking the provider and any database or authentication libraries used by the route. The Next.js Route Handler guide documents the request and response contract.
Build the smallest useful client
In AI SDK 6, useChat comes from @ai-sdk/react and uses a default
DefaultChatTransport that posts to /api/chat. The hook no longer owns the text
input, so ordinary React state is a better place for the draft. Messages expose an
ordered parts array; rendering text parts explicitly leaves room for reasoning,
tool, and data parts later.
'use client'
import { useChat } from '@ai-sdk/react'
import { useState, type FormEvent } from 'react'
export default function ChatPage() {
const [input, setInput] = useState('')
const { messages, sendMessage, status, error } = useChat()
const isBusy = status === 'submitted' || status === 'streaming'
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const text = input.trim()
if (!text || isBusy) return
void sendMessage({ text })
setInput('')
}
return (
<main className="mx-auto flex h-screen max-w-3xl flex-col gap-4 p-6">
<div className="flex-1 space-y-4 overflow-y-auto" aria-live="polite">
{messages.map((message) => (
<article key={message.id} className="space-y-1">
<p className="text-xs uppercase text-zinc-500">
{message.role === 'user' ? 'You' : 'Assistant'}
</p>
{message.parts.map((part, index) => {
if (part.type !== 'text') return null
return (
<p
key={`${message.id}-${index}`}
className="whitespace-pre-wrap text-sm"
>
{part.text}
</p>
)
})}
</article>
))}
{isBusy && <p className="text-sm text-zinc-500">Thinking...</p>}
</div>
{error && (
<p role="alert" className="text-sm text-red-400">
{error.message}
</p>
)}
<form onSubmit={handleSubmit} className="flex gap-2">
<label htmlFor="prompt" className="sr-only">
Message
</label>
<input
id="prompt"
value={input}
onChange={(event) => setInput(event.currentTarget.value)}
className="flex-1 rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2"
placeholder="Ask a question"
disabled={isBusy}
/>
<button
type="submit"
disabled={isBusy || input.trim().length === 0}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black disabled:opacity-50"
>
Send
</button>
</form>
</main>
)
}status distinguishes a request that was submitted from one that is actively
streaming. That is enough to prevent duplicate submissions and explain why the
button is temporarily disabled. The hook also exposes stop() when the UI needs a
cancel button. A partial assistant message is a normal failure state, so the UI
should leave room for retrying it rather than treating every interrupted response
as a successful completion.
Return data the rest of the app can trust
When the next function expects data, ask for a schema-validated result instead of
parsing whatever prose happened to come back. In AI SDK 6, the older
generateObject and streamObject helpers are deprecated in favor of
generateText or streamText with an Output specification.
Here is a finite JSON endpoint. The prompt includes an observation rather than claiming that the language model can fetch live weather; a real application would obtain that observation from a weather API or a tool first.
// app/api/weather-summary/route.ts
import { openai } from '@ai-sdk/openai'
import { generateText, Output } from 'ai'
import { z } from 'zod'
const WeatherSchema = z.object({
summary: z.string(),
temperatureC: z.number(),
})
export async function POST() {
try {
const { output } = await generateText({
model: openai('gpt-5.1'),
output: Output.object({ schema: WeatherSchema }),
prompt:
'Summarize this observation as structured data: Paris is 18 degrees Celsius with light rain.',
})
return Response.json(output)
} catch (error) {
console.error('Weather summary generation failed', error)
return Response.json(
{ error: 'Unable to generate a weather summary' },
{ status: 502 },
)
}
}The schema has two jobs: it gives the model a target shape and validates the
result before the route returns it. If the caller needs incremental structured
updates, use streamText with the same Output.object specification and consume
its partialOutputStream; that is a different response contract from the chat UI
stream and should be designed as such.
What belongs around the SDK
The SDK handles model-provider adapters, message conversion, and streaming transport. The application still owns the rules that make those pieces safe and useful:
- reject empty or oversized prompts before they reach the provider,
- cap conversation history, output length, and tool-call depth,
- keep system prompts and tool descriptions versioned with the code,
- persist a completed message from the server after the stream finishes,
- record model, latency, usage, and finish reason without logging sensitive prompt content by default,
- test slow responses, provider errors, browser disconnects, and cancellation,
- give users a retry path when a response stops halfway through.
Start with the plain chat loop and make each state visible: ready, submitted, streaming, and failed. Add markdown, persistence, tools, or evaluations when the basic request can survive a slow network and an unavailable provider. That order makes failures easier to understand and gives every later feature a reliable transport to build on.