---
title: "Vercel AI SDK i useChat: streaming bez klucza API"
description: "Działający czat na Vercel AI SDK 7 w Next.js: jak useChat streamuje odpowiedź, jak wyglądają status i części wiadomości, na modelu-atrapie bez klucza API."
canonical_url: "https://www.outofplace.space/pl/blog/vercel-ai-sdk-usechat-przyklad-bez-klucza-api"
language: "pl"
last_updated: "2026-11-02"
alternates:
  en: "https://www.outofplace.space/blog/vercel-ai-sdk-usechat-streaming.md"
---

# Vercel AI SDK i useChat: streaming bez klucza API

> Działający czat na Vercel AI SDK 7 w Next.js: jak useChat streamuje odpowiedź, jak wyglądają status i części wiadomości, na modelu-atrapie bez klucza API.

Opublikowano 2026-11-02 · Kategoria: Vercel i AI SDK · Tagi: AI SDK, Next.js, React

Żeby użyć Vercel AI SDK w aplikacji Next.js, wyrenderuj `useChat` z `@ai-sdk/react` w Client Component, skieruj jego `DefaultChatTransport` na route handler, a w nim zwróć wynik `streamText` przez `createUIMessageStreamResponse({ stream: toUIMessageStream(...) })`. `useChat` daje wtedy `messages`, których tekst przychodzi w `message.parts`, oraz `status`, który przechodzi przez `submitted`, `streaming` i `ready`. Nasze demo robi dokładnie to na AI SDK 7 (`ai` 7.0.152, `@ai-sdk/react` 4.0.156) i Next.js 16.4.3, z modelem-atrapą w miejscu dostawcy: bez klucza API, bez płatnych wywołań, a osiem testów w Playwright na buildzie produkcyjnym sprawdza, co robi demo.

Demo na żywo: https://lab.outofplace.space/demos/vercel-ai-sdk-usechat-streaming (Next.js 16.4.3; kod z commita 4861ddc: https://github.com/outof-place/outofplace-lab/tree/4861ddc9b87d3cdb4ca92e2c0f8557b8cc49c727/app/demos/vercel-ai-sdk-usechat-streaming)

## Czym jest Vercel AI SDK: AI SDK Core i AI SDK UI

[Wprowadzenie do AI SDK](https://ai-sdk.dev/docs/introduction) dzieli zestaw na dwie części. AI SDK Core to „ujednolicone API do generowania tekstu, obiektów, wywołań narzędzi i budowania agentów z LLM”: działa na serwerze, a `streamText` to jego wywołanie ze streamingiem. AI SDK UI to „zestaw niezależnych od frameworka hooków do budowania czatów i interfejsów generatywnych”: do czatu służy `useChat`, który działa w przeglądarce. Aplikacja z czatem używa obu, połączonych strumieniem HTTP, a demo ma po jednym pliku na każdą stronę i osobny na model-atrapę.

| | Działa w | W demie | Pakiet |
| --- | --- | --- | --- |
| AI SDK UI | przeglądarce, w Client Component | `useChat` w `chat.tsx` | `@ai-sdk/react` 4.0.156 |
| AI SDK Core | na serwerze, w route handlerze | `streamText` w `api/chat/route.ts` | `ai` 7.0.152 |
| Model | tam, gdzie dostawca | `MockLanguageModelV4` w `canned-model.ts` | `ai/test` |

`useChat` to hook Reacta, więc plik, który go wywołuje, zaczyna się od `"use client"`, a wszystko, co importuje, trafia do przeglądarki: nasze demo o tym, [co trafia do przeglądarki z Server i Client Components](https://www.outofplace.space/pl/blog/react-server-components-co-trafia-do-przegladarki), pokazuje, gdzie przebiega ta granica. Route handler zostaje na serwerze, czyli tam, gdzie powinien być klucz API dostawcy.

## Co daje useChat: status, wiadomości i części

[Przewodnik po czatach](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot) wymienia cztery wartości `status`: `submitted`, gdy wiadomość wysłano, a strumień odpowiedzi jeszcze się nie zaczął, `streaming`, gdy przychodzą kolejne fragmenty, `ready`, gdy cała odpowiedź dotarła i można wysłać następną wiadomość, oraz `error`, gdy żądanie się nie powiodło. `messages` to rozmowa, a każda wiadomość ma `parts` zamiast jednego stringa `content`, więc tekst, wywołania narzędzi i inne typy części przychodzą na jednej liście. Wysyła się przez `sendMessage({ text })`. Pole tekstowe należy do ciebie: `useChat` już nim nie zarządza.

Cały interfejs to komponent kliencki dema:

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

Trzy szczegóły warto skopiować:

- **Pole tekstowe to zwykły stan Reacta.** Formularz czyści je po wywołaniu `sendMessage`, a `useChat` od razu dodaje wiadomość użytkownika do `messages`: test widzi ją na liście tuż po kliknięciu, gdy status nadal pokazuje `submitted` albo `streaming`.
- **Tekst pochodzi z części.** Odpowiedź powstaje z części o `type === "text"`. Demo wypisuje pod odpowiedzią typ każdej części, a test odczytuje `parts: step-start, text`: część `step-start` oznacza początek kroku modelu, a tekst leży w jednej części `text`, która rośnie z każdym fragmentem.
- **Zajęty znaczy submitted albo streaming.** Przykład z dokumentacji blokuje pole, gdy `status !== 'ready'`, czyli również przy `error`. Demo blokuje je tylko wtedy, gdy odpowiedź jest w drodze. Test sprawdza, że pole jest zablokowane w trakcie streamingu, znów działa przy `ready` i że druga wiadomość przechodzi.

## Jak useChat streamuje odpowiedź

`useChat` wysyła całą rozmowę do trasy jako `POST` po HTTP i czyta odpowiedź jako strumień. `DefaultChatTransport` domyślnie wysyła na `/api/chat`; trasa dema leży obok jego strony, więc podaje `api`. Trasa wzoruje się na trasie z dokumentacji, z atrapą zamiast identyfikatora modelu i bez `instructions` oraz `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` zamienia wiadomości UI (z ich częściami) na to, co przyjmuje model. `streamText` wywołuje model i streamuje to, co zwraca. `toUIMessageStream` zamienia strumień modelu na fragmenty wiadomości UI, a `createUIMessageStreamResponse` wysyła je jako odpowiedź HTTP. Te dwa bezstanowe helpery to sposób odpowiadania w AI SDK 7: [przewodnik migracji do 7.0](https://ai-sdk.dev/docs/migration-guides/migration-guide-7-0) oznacza jako przestarzałe `result.toUIMessageStreamResponse()`, które pokazuje większość tutoriali. W v7 nadal działa, z ostrzeżeniem, i zniknie w następnej wersji głównej.

[Route handlery w Next.js](https://nextjs.org/docs/app/getting-started/route-handlers) nie są cache’owane, poza handlerami `GET`, które się do tego włączy, więc trasa `POST` taka jak ta działa przy każdym żądaniu.

### Co płynie w strumieniu

Strumień wiadomości UI to Server-Sent Events: jedna linia `data:` z JSON-em na fragment. [Strona o protokole strumienia](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol) wymienia wszystkie typy fragmentów i mówi, że własny backend musi wysłać nagłówek `x-vercel-ai-ui-message-stream: v1`. Test dema wysyła jedną wiadomość prosto do trasy i czyta, co wraca:

| Zdarzenie, po kolei | Co oznacza (protokół strumienia) | W odpowiedzi dema |
| --- | --- | --- |
| `start` | zaczyna się nowa wiadomość | raz |
| `start-step` | zaczyna się jedno wywołanie modelu | raz, staje się częścią `step-start` |
| `text-start` | zaczyna się blok tekstu, z id | raz |
| `text-delta` | kolejny tekst dla tego bloku, w `delta` | 17 razy, po jednym na słowo |
| `text-end` | blok tekstu jest kompletny | raz |
| `finish-step`, `finish` | koniec wywołania modelu, potem wiadomości | po razie |
| `[DONE]` | koniec strumienia | na końcu |

Odpowiedź przychodzi jako `text/event-stream` z nagłówkiem `v1`, a siedemnaście złączonych delt daje całą odpowiedź. W przeglądarce test zapisuje każdą wersję tekstu odpowiedzi, jaką pokazuje strona: jest ich co najmniej pięć, każda wyrasta z poprzedniej, więc tekst przychodzi w kilku fragmentach, a nie naraz. W tej samej wymianie status pokazuje kolejno `ready`, `submitted`, `streaming`, `ready`.

Domyślnie `useChat` publikuje aktualizację wiadomości po każdym przetworzonym fragmencie. Dla długich, szybkich odpowiedzi przewodnik po czatach proponuje `throttle` (na przykład `throttle: 50`, w milisekundach), żeby renderować rzadziej; demo go nie używa, żeby było widać każde słowo.

## Jak uruchomić to bez klucza API: model-atrapa

Model to jedyna część dema, która nie jest kodem produkcyjnym:

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` pochodzi z `ai/test`, czyli z [narzędzi do testów](https://ai-sdk.dev/docs/ai-sdk-core/testing) AI SDK. Jego `doStream` zwraca to samo, co model dostawcy: `text-start`, fragmenty `text-delta`, `text-end` i `finish` ze zużyciem tokenów. [`simulateReadableStream`](https://ai-sdk.dev/docs/reference/ai-sdk-core/simulate-readable-stream) wysyła je z opóźnieniami, które domyślnie wynoszą 0. Demo czeka 600 ms przed pierwszym słowem, w zastępstwie czasu do pierwszego tokena, i 150 ms między słowami, żeby dało się to obejrzeć. `simulateReadableStream` importuj z `ai`, tak jak robi to strona o testach.

Inny test sprawdza, że nic nie wychodzi na zewnątrz: każde żądanie strony podczas wysyłania idzie do jej własnego originu, a jedyny `POST` trafia do trasy dema.

### Dlaczego route handler, a nie DirectChatTransport

AI SDK ma jeszcze dwa sposoby na ominięcie sieci. [`DirectChatTransport`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/direct-chat-transport) rozmawia z agentem „w tym samym procesie, bez HTTP” i nie umie wznowić strumienia. Strona o testach pokazuje też trasę, która zwraca ręcznie napisane stringi SSE przez `simulateReadableStream`. Oba sposoby testują interfejs, ale żaden nie uruchamia trasy, którą wdrożysz. Z atrapą za `streamText` wszystko poza modelem jest kodem, który trafi na produkcję, a test może sprawdzić, co przechodzi przez sieć.

## Jak dodać czat na Vercel AI SDK do aplikacji Next.js

**1. Zainstaluj dwa pakiety.**
Uruchom `pnpm add ai @ai-sdk/react`. Według przewodnika migracji do 7.0 AI SDK 7 wymaga Node.js 22 lub nowszego i działa tylko jako ESM.

**2. Napisz route handler.**
Utwórz `app/api/chat/route.ts` z `POST`, który czyta `messages`, zamienia je przez `convertToModelMessages`, wywołuje `streamText` i zwraca `createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) })`. Prompt systemowy podaj w `instructions`: AI SDK 7 zmieniło tę nazwę z `system`.

**3. Zacznij od modelu-atrapy.**
Jako model przekaż `MockLanguageModelV4` z `ai/test`, z fragmentami z `simulateReadableStream`. Interfejs da się zbudować i przetestować, zanim ktokolwiek dostanie klucz API.

**4. Zbuduj czat w Client Component.**
W pliku z `"use client"` wywołaj `useChat` z `DefaultChatTransport` (ustaw `api`, jeśli trasa nie leży pod `/api/chat`), trzymaj pole tekstowe w `useState`, przy wysłaniu wywołuj `sendMessage({ text })` i renderuj części wiadomości o `type === "text"`.

**5. Steruj interfejsem przez status.**
Blokuj formularz, gdy `status` to `submitted` albo `streaming`, pokaż spinner przy `submitted` i daj przycisk Stop wywołujący `stop()`, gdy odpowiedź jest streamowana.

**6. Przetestuj na buildzie produkcyjnym.**
Uruchom `next build` i `next start`, a potem sprawdź w Playwright to, co widzi użytkownik: kolejność statusów, tekst rosnący w kilku krokach i żądania idące tylko do twojej trasy.

**7. Podmień model na prawdziwy.**
Zastąp atrapę modelem dostawcy w trasie, trzymaj klucz w zmiennej środowiskowej na serwerze i ustaw `maxDuration` w trasie, żeby długie odpowiedzi nie były ucinane. Po stronie klienta nic się nie zmienia.

## Co psuje czat na useChat

### Tutoriale pisane pod AI SDK 4

Wiele tutoriali o Vercel AI SDK nadal używa `input` i `handleSubmit` z `ai/react`. [Przewodnik migracji do 5.0](https://ai-sdk.dev/docs/migration-guides/migration-guide-5-0) usunął to API, a AI SDK 7 znów zmieniło stronę serwerową:

| W starszym tutorialu | W AI SDK 7 |
| --- | --- |
| `import { useChat } from 'ai/react'` | `import { useChat } from '@ai-sdk/react'` |
| `input`, `handleInputChange` | własny `useState` |
| `handleSubmit` | obsługa wysłania formularza, która wywołuje `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` |

Trzy ostatnie nadal działają w v7 jako przestarzałe aliasy. Pierwsze siedem nie działa.

### Przykład streamingu w dokumentacji Next.js

Na dzień 2 listopada 2026 przykład streamingu w [dokumentacji route.js w Next.js](https://nextjs.org/docs/app/api-reference/file-conventions/route) nadal importuje `StreamingTextResponse` i wywołuje `result.toAIStream()`. [Przewodnik migracji do AI SDK 4.0](https://ai-sdk.dev/docs/migration-guides/migration-guide-4-0) usunął oba: „The `StreamingTextResponse` function has been removed”, a `toAIStream` jest na jego liście metod usuniętych z wyniku `streamText`. Kształt trasy bierz z przewodnika po czatach AI SDK.

### Trasa obok strony

`route.ts` nie może leżeć w tym samym segmencie trasy co `page.tsx`, jak mówi przewodnik po route handlerach. Daj jej własny folder (`app/api/chat/route.ts` albo `api/chat/` pod stroną, jak w demie) i powiedz transportowi, gdzie jest.

### Zablokowane pole przy błędzie

Przy `disabled={status !== 'ready'}` nieudane żądanie zostawia formularz zablokowany, bo `error` to nie `ready`. Blokuj tylko przy `submitted` i `streaming`, a błąd pokaż z przyciskiem Retry, który wywołuje `regenerate()`, jak w przewodniku po czatach.

### Długie odpowiedzi na hostingu serverless

Trasa z przewodnika po czatach ustawia `maxDuration = 30`, żeby pozwolić na streaming odpowiedzi do 30 sekund. [`maxDuration`](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/maxDuration) to opcja segmentu w Next.js, w sekundach, którą platformy hostingowe mogą odczytać z wyniku builda, żeby ustawić limit czasu funkcji. Ustaw ją tak, żeby starczyła na najdłuższą odpowiedź, w granicach twojego planu.

## Na jakich wersjach to sprawdziliśmy

AI SDK 7 to obecna wersja główna, a jej pakiety szybko się zmieniają: wersje w labie są tydzień za najnowszymi. Daty poniżej to czasy publikacji z wpisów w rejestrze npm dla [pakietu ai](https://registry.npmjs.org/ai) i [pakietu @ai-sdk/react](https://registry.npmjs.org/@ai-sdk/react), odczytane 2 listopada 2026.

| Pakiet | Pierwsze wydanie 7.x / 4.x | W labie | Najnowsze 2 listopada 2026 |
| --- | --- | --- | --- |
| `ai` | 7.0.0, 25 czerwca 2026 | 7.0.152, 23 października 2026 | 7.0.159, 30 października 2026 |
| `@ai-sdk/react` | 4.0.0, 25 czerwca 2026 | 4.0.156, 23 października 2026 | 4.0.163, 30 października 2026 |

Na 7.0.159 i 4.0.163 jeszcze dema nie uruchamialiśmy. Gdy lab się zaktualizuje, te same osiem testów przejdzie ponownie, a ten tekst zmieni się, jeśli któryś nie przejdzie.

## Dalsza lektura

- [AI SDK: Chatbot](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot): useChat od początku do końca: wartości status, stop i regenerate, throttle, callbacki i opcje żądania.

- [AI SDK: Stream protocols](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol): Każdy typ fragmentu strumienia wiadomości UI, nagłówek v1 i alternatywa w czystym tekście.

- [AI SDK: Testing](https://ai-sdk.dev/docs/ai-sdk-core/testing): MockLanguageModelV4 i inne narzędzia z ai/test, z przykładami dla streamText i strumienia UI.

- [AI SDK: simulateReadableStream](https://ai-sdk.dev/docs/reference/ai-sdk-core/simulate-readable-stream): Dokumentacja: fragmenty, opóźnienie początkowe i między fragmentami oraz ich wartości domyślne.

- [AI SDK: Migrate to 7.0](https://ai-sdk.dev/docs/migration-guides/migration-guide-7-0): Node.js 22, tylko ESM, instructions, bezstanowe helpery strumienia i wszystkie inne zmiany nazw.

- [AI SDK: Migrate to 5.0](https://ai-sdk.dev/docs/migration-guides/migration-guide-5-0): Gdzie w useChat podziały się input, handleSubmit, append, content i isLoading.

- [Next.js: Route Handlers](https://nextjs.org/docs/app/getting-started/route-handlers): Gdzie może leżeć route.ts, które metody są cache’owane i Cache Components dla GET.
