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.

Reading time: 8 min readPublished:

To use the Vercel AI SDK in a Next.js app, render useChat from @ai-sdk/react 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/react 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.

Type a message and press Send: the status goes submitted, streaming, ready, and the reply grows word by word. The parts line lists what the message is made of. A reload clears the chat. · Next.js 16.4.3Open the demoView source

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 inIn the demoPackage
AI SDK UIthe browser, in a Client ComponentuseChat in chat.tsx@ai-sdk/react 4.0.156
AI SDK Corethe server, in a route handlerstreamText in api/chat/route.tsai 7.0.152
The modelwherever the provider isMockLanguageModelV4 in canned-model.tsai/test

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:

app/demos/vercel-ai-sdk-usechat-streaming/chat.tsx
"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>  );}
From the lab at commit 4861ddc

Three details in it are worth copying:

  • The input is plain React state. The form clears it after calling sendMessage, and useChat adds the user's message to messages straight away: the test sees it in the list right after the click, while the status still shows submitted or streaming.
  • 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 reads parts: step-start, text: a step-start part marks the start of the model step, and the text sits in one text part that grows as chunks arrive.
  • Busy means submitted or streaming. The docs' own example disables the input whenever status !== 'ready', which also locks it on error. 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 at ready, 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 /api/chat 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:

app/demos/vercel-ai-sdk-usechat-streaming/api/chat/route.ts
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 }),  });}
From the lab at commit 4861ddc

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 orderWhat it marks (stream protocol)In the demo's response
starta new message beginsonce
start-stepone model call beginsonce, and it becomes the step-start part
text-starta text block begins, with an idonce
text-deltamore text for that block, in delta17 times, one per word
text-endthe text block is completeonce
finish-step, finishthe model call, then the message, are doneonce each
[DONE]the stream endslast

The response comes as text/event-stream 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:

app/demos/vercel-ai-sdk-usechat-streaming/canned-model.ts
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 },            },          },        ],      }),    }),  });}
From the lab at commit 4861ddc

MockLanguageModelV4 comes from ai/test, 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

  1. Install the two packages

    Run pnpm add ai @ai-sdk/react. AI SDK 7 needs Node.js 22 or later and is ESM-only, according to the 7.0 migration guide.

  2. Write the route handler

    Create app/api/chat/route.ts with a POST that reads messages, converts them with convertToModelMessages, calls streamText and returns createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) }). Add instructions for a system prompt: AI SDK 7 renamed it from system.

  3. Start with a mock model

    Pass a MockLanguageModelV4 from ai/test as the model, with simulateReadableStream chunks. The UI can be built and tested before anyone has an API key.

  4. Build the chat in a Client Component

    In a "use client" file, call useChat with a DefaultChatTransport (set api if the route is not at /api/chat), keep the input in useState, call sendMessage({ text }) on submit, and render each message's parts with type === "text".

  5. Drive the UI from status

    Disable the form while status is submitted or streaming, show a spinner on submitted, and offer a Stop button that calls stop() while a reply streams.

  6. Test it on a production build

    Run next build and next 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.

  7. 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 maxDuration on 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/react. 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 tutorialIn AI SDK 7
import { useChat } from 'ai/react'import { useChat } from '@ai-sdk/react'
input, handleInputChangeyour own useState
handleSubmita submit handler that calls sendMessage({ text })
append(message)sendMessage(message)
useChat({ api: '/api/chat' })useChat({ transport: new DefaultChatTransport({ api }) })
message.contentmessage.parts
isLoadingstatus
result.toUIMessageStreamResponse()createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) })
system: '…'instructions: '…'
result.fullStreamresult.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/api/chat/route.ts, or api/chat/ 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.

PackageFirst 7.x / 4.x releaseThe lab runsLatest on 2 Nov 2026
ai7.0.0, 25 June 20267.0.152, 23 Oct 20267.0.159, 30 Oct 2026
@ai-sdk/react4.0.0, 25 June 20264.0.156, 23 Oct 20264.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.

Further reading

AI SDK: Chatbot (opens in a new tab)useChat end to end: status values, stop and regenerate, throttle, callbacks and request options.ai-sdk.dev
AI SDK: Stream protocols (opens in a new tab)Every chunk type of the UI message stream, the v1 header and the plain-text alternative.ai-sdk.dev
AI SDK: Testing (opens in a new tab)MockLanguageModelV4 and the other helpers in ai/test, with streamText and UI stream examples.ai-sdk.dev
AI SDK: simulateReadableStream (opens in a new tab)The reference: chunks, the initial and per-chunk delays and their defaults.ai-sdk.dev
AI SDK: Migrate to 7.0 (opens in a new tab)Node.js 22, ESM only, instructions, the stateless stream helpers and every other rename.ai-sdk.dev
AI SDK: Migrate to 5.0 (opens in a new tab)Where input, handleSubmit, append, content and isLoading went in useChat.ai-sdk.dev
Next.js: Route Handlers (opens in a new tab)Where a route.ts may live, which methods are cached, and Cache Components for GET.nextjs.org

Tell us what we’re building.

We reply within one business day.