Vercel AI SDK with Next.js

Vercel AI SDK with Next.js cover image

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 managed useChat input state, toDataStreamResponse(), and streamObject(). Those snippets are an archive. The official documentation checked for this revision describes AI SDK 6 as the stable line, with a transport-based useChat API and toUIMessageStreamResponse(). The examples below use ai@^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-app

Install 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 zod

Put the provider key in .env.local:

# .env.local
OPENAI_API_KEY=sk-your-key

The 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.