---
title: "Vercel AI SDK useChat: streaming with no API key"
description: "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."
canonical_url: "https://www.outofplace.space/blog/vercel-ai-sdk-usechat-streaming"
language: "en"
last_updated: "2026-11-02"
alternates:
  pl: "https://www.outofplace.space/pl/blog/vercel-ai-sdk-usechat-przyklad-bez-klucza-api.md"
---

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

Published 2026-11-02 · Category: Vercel & AI SDK · Tags: AI SDK, Next.js, React

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.

Live demo: https://lab.outofplace.space/demos/vercel-ai-sdk-usechat-streaming (Next.js 16.4.3; source at commit 4861ddc: https://github.com/outof-place/outofplace-lab/tree/4861ddc9b87d3cdb4ca92e2c0f8557b8cc49c727/app/demos/vercel-ai-sdk-usechat-streaming)

## What the Vercel AI SDK is: AI SDK Core and AI SDK UI

The [AI SDK introduction](https://ai-sdk.dev/docs/introduction) 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/react` 4.0.156 |
| AI SDK Core | the server, in a route handler | `streamText` in `api/chat/route.ts` | `ai` 7.0.152 |
| The model | wherever the provider is | `MockLanguageModelV4` in `canned-model.ts` | `ai/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](https://www.outofplace.space/blog/react-server-components-explained) 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](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot) 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 (https://github.com/outof-place/outofplace-lab/blob/4861ddc9b87d3cdb4ca92e2c0f8557b8cc49c727/app/demos/vercel-ai-sdk-usechat-streaming/chat.tsx)

```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>
  );
}
```

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 (https://github.com/outof-place/outofplace-lab/blob/4861ddc9b87d3cdb4ca92e2c0f8557b8cc49c727/app/demos/vercel-ai-sdk-usechat-streaming/api/chat/route.ts)

```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 }),
  });
}
```

`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](https://ai-sdk.dev/docs/migration-guides/migration-guide-7-0) 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](https://nextjs.org/docs/app/getting-started/route-handlers) 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](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol) 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/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 (https://github.com/outof-place/outofplace-lab/blob/4861ddc9b87d3cdb4ca92e2c0f8557b8cc49c727/app/demos/vercel-ai-sdk-usechat-streaming/canned-model.ts)

```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 },
            },
          },
        ],
      }),
    }),
  });
}
```

`MockLanguageModelV4` comes from `ai/test`, the AI SDK's [testing helpers](https://ai-sdk.dev/docs/ai-sdk-core/testing). Its `doStream` returns what a provider's model would: `text-start`, `text-delta` chunks, `text-end` and `finish` with token usage. [`simulateReadableStream`](https://ai-sdk.dev/docs/reference/ai-sdk-core/simulate-readable-stream) 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`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/direct-chat-transport) 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](https://ai-sdk.dev/docs/migration-guides/migration-guide-5-0) removed that API, and AI SDK 7 changed the server side again:

| In an older tutorial | In AI SDK 7 |
| --- | --- |
| `import { useChat } from 'ai/react'` | `import { useChat } from '@ai-sdk/react'` |
| `input`, `handleInputChange` | your own `useState` |
| `handleSubmit` | a submit handler that calls `sendMessage({ text })` |
| `append(message)` | `sendMessage(message)` |
| `useChat({ api: '/api/chat' })` | `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](https://nextjs.org/docs/app/api-reference/file-conventions/route) still imports `StreamingTextResponse` and calls `result.toAIStream()`. The [AI SDK 4.0 migration guide](https://ai-sdk.dev/docs/migration-guides/migration-guide-4-0) 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`](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/maxDuration) 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](https://registry.npmjs.org/ai) and [the @ai-sdk/react package](https://registry.npmjs.org/@ai-sdk/react), 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/react` | 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.

## Further reading

- [AI SDK: Chatbot](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot): useChat end to end: status values, stop and regenerate, throttle, callbacks and request options.

- [AI SDK: Stream protocols](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol): Every chunk type of the UI message stream, the v1 header and the plain-text alternative.

- [AI SDK: Testing](https://ai-sdk.dev/docs/ai-sdk-core/testing): MockLanguageModelV4 and the other helpers in ai/test, with streamText and UI stream examples.

- [AI SDK: simulateReadableStream](https://ai-sdk.dev/docs/reference/ai-sdk-core/simulate-readable-stream): The reference: chunks, the initial and per-chunk delays and their defaults.

- [AI SDK: Migrate to 7.0](https://ai-sdk.dev/docs/migration-guides/migration-guide-7-0): Node.js 22, ESM only, instructions, the stateless stream helpers and every other rename.

- [AI SDK: Migrate to 5.0](https://ai-sdk.dev/docs/migration-guides/migration-guide-5-0): Where input, handleSubmit, append, content and isLoading went in useChat.

- [Next.js: Route Handlers](https://nextjs.org/docs/app/getting-started/route-handlers): Where a route.ts may live, which methods are cached, and Cache Components for GET.
