Vercel AI SDK useChat: streaming with no API key
A live Vercel AI SDK 7 chat in Next.js: how useChat streams a reply, what its status and message parts look like, run on a mock model with no API key.

To use the Vercel AI SDK in a Next.js app, render useChat from @ai-sdk/ in a Client Component, point its DefaultChatTransport at a route handler, and have that route return streamText output with createUIMessageStreamResponse({ stream: toUIMessageStream(...) }). useChat then gives you messages, whose text arrives in message.parts, and a status that goes submitted, streaming, ready. Our demo runs exactly that on AI SDK 7 (ai 7.0.152, @ai-sdk/ 4.0.156) and Next.js 16.4.3, with a mock model where a provider would go: no API key, no paid call, and eight Playwright tests on a production build check what the demo does.
What the Vercel AI SDK is: AI SDK Core and AI SDK UI
The AI SDK introduction (opens in a new tab) splits the toolkit in two. AI SDK Core is "a unified API for generating text, structured objects, tool calls, and building agents with LLMs": it runs on the server, and streamText is its streaming call. AI SDK UI is "a set of framework-agnostic hooks for quickly building chat and generative user interfaces": useChat is the one for chat, and it runs in the browser. A chat app uses both, joined by an HTTP stream, and the demo has one file for each side plus the mock model.
| Runs in | In the demo | Package | |
|---|---|---|---|
| AI SDK UI | the browser, in a Client Component | useChat in chat.tsx | @ai-sdk/ 4.0.156 |
| AI SDK Core | the server, in a route handler | streamText in api/ | ai 7.0.152 |
| The model | wherever the provider is | MockLanguageModelV4 in canned-model.ts | ai/ |
useChat is a React hook, so the file that calls it starts with "use client", and everything it imports ships to the browser: our demo of what reaches the browser from Server and Client Components shows where that line falls. The route handler stays on the server, which is where a provider's API key belongs.
What useChat gives you: status, messages and parts
The chatbot guide (opens in a new tab) lists four values for status: submitted when the message has been sent and the response stream has not started, streaming while chunks arrive, ready when the full response has been received and a new message can be sent, and error when the request failed. messages is the conversation, and each message carries parts instead of one content string, so text, tool calls and other part types arrive in one list. You send with sendMessage({ text }). The input field is yours: useChat no longer manages it.
The demo's client component is the whole UI:
"use client"; import { useChat } from "@ai-sdk/react";import { DefaultChatTransport } from "ai";import { useState } from "react"; import styles from "./chat.module.css"; export function Chat() { const { messages, sendMessage, status } = useChat({ // The default endpoint is /api/chat; this demo's route sits next to its page. transport: new DefaultChatTransport({ api: "/demos/vercel-ai-sdk-usechat-streaming/api/chat" }), }); const [input, setInput] = useState(""); // Busy only while a reply is on its way, so an "error" status does not lock the input. const busy = status === "submitted" || status === "streaming"; return ( <section className={styles.chat} aria-label="Chat"> <p className={styles.status}> status <output aria-label="Status">{status}</output> </p> <ol className={styles.messages} aria-label="Messages"> {messages.map((message) => ( <li key={message.id} className={styles.message} data-role={message.role}> <span className={styles.role}>{message.role === "user" ? "You" : "Assistant"}</span> <p> {message.parts.map((part, index) => part.type === "text" ? ( <span key={index} data-part="text"> {part.text} </span> ) : null, )} </p> {message.role === "assistant" && ( <code className={styles.parts}>parts: {message.parts.map((part) => part.type).join(", ")}</code> )} </li> ))} </ol> <form className={styles.form} onSubmit={(event) => { event.preventDefault(); if (!input.trim()) return; sendMessage({ text: input }); setInput(""); }} > <input aria-label="Message" value={input} onChange={(event) => setInput(event.target.value)} disabled={busy} placeholder="Say something" /> <button type="submit" disabled={busy || !input.trim()}> Send </button> </form> </section> );}Three details in it are worth copying:
- The input is plain React state. The form clears it after calling
sendMessage, anduseChatadds the user's message tomessagesstraight away: the test sees it in the list right after the click, while the status still showssubmittedorstreaming. - Text comes from parts. The reply is rendered from parts with
type === "text". The demo prints every part's type under the reply, and the test readsparts: step-start, text: astep-startpart marks the start of the model step, and the text sits in onetextpart that grows as chunks arrive. - Busy means submitted or streaming. The docs' own example disables the input whenever
status !== 'ready', which also locks it onerror. The demo disables it only while a reply is on its way. The test checks that the input is disabled while the reply streams, usable again atready, and that a second message works.
How useChat streams a reply
useChat sends the whole conversation to the route as an HTTP POST, and reads the reply back as a stream. DefaultChatTransport posts to / by default; the demo's route sits next to its page, so it passes api. The route follows the docs' route, with the mock in place of a model id and without the docs' instructions and maxDuration:
import { convertToModelMessages, createUIMessageStreamResponse, streamText, toUIMessageStream, type UIMessage } from "ai"; import { cannedModel } from "../../canned-model"; // useChat posts the whole conversation here and reads the reply as a UI message stream.export async function POST(req: Request) { const { messages }: { messages: UIMessage[] } = await req.json(); const result = streamText({ model: cannedModel(), // a real provider's model goes here; nothing else in this file changes messages: await convertToModelMessages(messages), }); return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }), });}convertToModelMessages turns the UI messages (with their parts) into what a model takes. streamText calls the model and streams what it returns. toUIMessageStream turns the model's stream into UI message chunks, and createUIMessageStreamResponse sends them as the HTTP response. These two stateless helpers are AI SDK 7's way to answer: the 7.0 migration guide (opens in a new tab) deprecates result.toUIMessageStreamResponse(), which most tutorials still show. It works in v7 with deprecation warnings and goes away in the next major release.
Next.js route handlers (opens in a new tab) are not cached, apart from GET handlers you opt in, so a POST route like this one runs on every request.
What the stream carries on the wire
The UI message stream is Server-Sent Events: one data: line of JSON per chunk. The stream protocol page (opens in a new tab) lists every chunk type, and says a custom backend must send the header x-vercel-ai-ui-message-stream: v1. The demo's test posts one message straight to the route and reads what comes back:
| Event, in order | What it marks (stream protocol) | In the demo's response |
|---|---|---|
start | a new message begins | once |
start-step | one model call begins | once, and it becomes the step-start part |
text-start | a text block begins, with an id | once |
text-delta | more text for that block, in delta | 17 times, one per word |
text-end | the text block is complete | once |
finish-step, finish | the model call, then the message, are done | once each |
[DONE] | the stream ends | last |
The response comes as text/ with the v1 header, and the seventeen deltas joined give back the full reply. In the browser, the test records every version of the reply text the page shows: it takes at least five values, each one growing from the last, so the text arrives over several chunks, not in one step. Over the same exchange the status shows ready, submitted, streaming, ready, in that order.
By default, useChat publishes a message update for each chunk it processes. For long, fast replies the chatbot guide offers throttle (for example throttle: 50, in milliseconds) to render less often; the demo leaves it off so every word shows.
Running it without an API key: a mock model
The model is the only part of the demo that is not production code:
import { simulateReadableStream } from "ai";import { MockLanguageModelV4 } from "ai/test"; const reply = "This reply is canned: a mock model streams it word by word, as a real provider would."; // A stand-in for a provider's model: no key, no network, no cost. It answers every message with// the same reply, one word per chunk, slow enough to watch. A new one per request, because a mock// keeps every call it receives (doStreamCalls), which a test wants and a server does not.export function cannedModel() { return new MockLanguageModelV4({ doStream: async () => ({ stream: simulateReadableStream({ initialDelayInMs: 600, // the wait for the first token chunkDelayInMs: 150, // the gap between tokens chunks: [ { type: "text-start", id: "text-1" }, ...reply.split(/(?<= )/).map((delta) => ({ type: "text-delta" as const, id: "text-1", delta })), { type: "text-end", id: "text-1" }, { type: "finish", finishReason: { unified: "stop", raw: undefined }, usage: { inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined }, outputTokens: { total: 17, text: 17, reasoning: undefined }, }, }, ], }), }), });}MockLanguageModelV4 comes from ai/, the AI SDK's testing helpers (opens in a new tab). Its doStream returns what a provider's model would: text-start, text-delta chunks, text-end and finish with token usage. simulateReadableStream (opens in a new tab) emits them with delays, both defaulting to 0. The demo waits 600 ms before the first word, a stand-in for the time to the first token, and 150 ms between words, slow enough to watch. Import simulateReadableStream from ai, as the testing page does.
Another test checks that nothing leaves: every request the page makes while sending goes to its own origin, and the only POST is to the demo's route.
Why a route handler, and not DirectChatTransport
The AI SDK offers two other ways to skip the network. DirectChatTransport (opens in a new tab) talks to an Agent "in-process, without going through HTTP", and cannot reconnect to a stream. The testing page also shows a route that returns hand-written SSE strings with simulateReadableStream. Both test your UI, but neither runs the route you will deploy. With the mock behind streamText, everything except the model is the code that goes to production, and the test can check what crosses the network.
How to add a Vercel AI SDK chat to a Next.js app
Install the two packages
Run
pnpm add ai @ai-sdk/. AI SDK 7 needs Node.js 22 or later and is ESM-only, according to the 7.0 migration guide.react Write the route handler
Create
app/with aapi/ chat/ route.ts POSTthat readsmessages, converts them withconvertToModelMessages, callsstreamTextand returnscreateUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) }). Addinstructionsfor a system prompt: AI SDK 7 renamed it fromsystem.Start with a mock model
Pass a
MockLanguageModelV4fromai/as the model, withtest simulateReadableStreamchunks. The UI can be built and tested before anyone has an API key.Build the chat in a Client Component
In a
"use client"file, calluseChatwith aDefaultChatTransport(setapiif the route is not at/), keep the input inapi/ chat useState, callsendMessage({ text })on submit, and render each message's parts withtype === "text".Drive the UI from status
Disable the form while
statusissubmittedorstreaming, show a spinner onsubmitted, and offer a Stop button that callsstop()while a reply streams.Test it on a production build
Run
next buildandnext start, then assert in Playwright what a user sees: the status sequence, text that grows over several updates, and requests that go only to your own route.Swap in a real model
Replace the mock with your provider's model in the route, put its key in an environment variable on the server, and set
maxDurationon the route so long replies are not cut off. Nothing in the client changes.
What breaks a useChat chat
Tutorials written for AI SDK 4
Many Vercel AI SDK tutorials still use input and handleSubmit from ai/. The 5.0 migration guide (opens in a new tab) removed that API, and AI SDK 7 changed the server side again:
| In an older tutorial | In AI SDK 7 |
|---|---|
import { useChat } from 'ai/ | import { useChat } from '@ai-sdk/ |
input, handleInputChange | your own useState |
handleSubmit | a submit handler that calls sendMessage({ text }) |
append(message) | sendMessage(message) |
useChat({ api: '/ | useChat({ transport: new DefaultChatTransport({ api }) }) |
message.content | message.parts |
isLoading | status |
result.toUIMessageStreamResponse() | createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) }) |
system: '…' | instructions: '…' |
result.fullStream | result.stream |
The last three still work in v7 as deprecated aliases. The first seven do not.
The Next.js docs' streaming example
As of 2 November 2026, the streaming example in the Next.js route.js reference (opens in a new tab) still imports StreamingTextResponse and calls result.toAIStream(). The AI SDK 4.0 migration guide (opens in a new tab) removed both: "The StreamingTextResponse function has been removed", and toAIStream is on its list of methods removed from the streamText result. Copy the route shape from the AI SDK's chatbot guide instead.
A route next to the page
A route.ts cannot sit in the same route segment as a page.tsx, as the route handlers guide says. Put it in its own folder (app/, or api/ under the page as the demo does), and tell the transport where it is.
Locking the input on error
With disabled={status !== 'ready'}, a failed request leaves the form disabled, since error is not ready. Disable on submitted and streaming only, and show the error with a Retry button that calls regenerate(), as the chatbot guide does.
Long replies on serverless hosting
The chatbot guide's route sets maxDuration = 30 to allow streaming responses up to 30 seconds. maxDuration (opens in a new tab) is a Next.js segment option in seconds that deployment platforms can read from the build output to set the function's execution limit. Set it to cover your longest reply, within what your plan allows.
Versions this was checked on
AI SDK 7 is the current major version, and its packages move fast: the lab's versions are a week behind the latest. The dates below are the publish times in the npm registry entries for the ai package (opens in a new tab) and the @ai-sdk/react package (opens in a new tab), read on 2 November 2026.
| Package | First 7.x / 4.x release | The lab runs | Latest on 2 Nov 2026 |
|---|---|---|---|
ai | 7.0.0, 25 June 2026 | 7.0.152, 23 Oct 2026 | 7.0.159, 30 Oct 2026 |
@ai-sdk/ | 4.0.0, 25 June 2026 | 4.0.156, 23 Oct 2026 | 4.0.163, 30 Oct 2026 |
We have not run the demo on 7.0.159 and 4.0.163 yet. When the lab upgrades, the same eight tests run again, and this post changes if they fail.
What is the Vercel AI SDK?
An open-source TypeScript toolkit from Vercel for building AI features. AI SDK Core runs on the server and calls models (streamText, generateText); AI SDK UI is a set of hooks such as useChat that keep the chat state in the browser and read the streamed reply.
Is the Vercel AI SDK free?
The ai package is open source under the Apache-2.0 licence, and installing it costs nothing. The model provider you connect bills for its own API calls; with a mock model, as in our demo, nothing is billed at all.
Why is handleSubmit or input missing from useChat?
AI SDK 5 removed them. useChat no longer manages the input: keep it in your own useState, and call sendMessage({ text }) from your form's submit handler. append became sendMessage, the api option moved into a transport, message.content became message.parts and isLoading became status.
What is the difference between useChat and useCompletion?
useChat keeps a list of messages, each made of parts, and reports a status through the whole exchange. useCompletion handles one text completion: it returns a single completion string and still manages its own input, so it suits a one-shot prompt box rather than a conversation.
Does the Vercel AI SDK work without Vercel hosting?
Yes. The route is a standard Next.js route handler and useChat is a React hook, with nothing tied to Vercel's platform. Our demo's tests run against a production build served by next start on a local machine.
