Command Palette

Search for a command to run...

Hectal
PHASE 5Intermediate ~28 min· topic 2 of 9

Topic 5.2

streamText and the Route Handler

In one line

The server half of an AI SDK chat is one function. It reads the messages, converts them with convertToModelMessages, calls streamText with a model and your instructions, and returns toUIMessageStreamResponse(). That response is a stream of small JSON chunks the browser hook understands.

Think of it like this

A waiter in a restaurant. Guests talk to the waiter in everyday words. The waiter writes an order slip in the kitchen's format, and the dishes come back one course at a time. The waiter carries each course out the moment it is ready, and if the kitchen burns the soup, the waiter tells the table something polite, not the chef's swearing. The route handler is the waiter: it translates both ways and decides what the guest is allowed to hear.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Route handler
A server function that answers one URL. In Next.js it is export async function POST(req) in app/api/chat/route.ts.
UIMessage
A message in the browser's shape: an id, a role and a list of parts.
Model message
A message in the shape the model expects: a role and its content. convertToModelMessages makes them from UI messages.
instructions
The system prompt that tells the model how to behave. Set on the server only.
UI message stream
The Server-Sent Events body that toUIMessageStreamResponse() sends: one JSON chunk per event.
Chunk
One JSON object in that stream, such as {"type":"text-delta","delta":"Hello","id":"…"}.
onError
A callback that receives any error raised while streaming. Because streamText does not throw, this is where you log failures.
onEnd
A callback that runs when the stream finishes, with the finish reason and token usage. onFinish is the older name.
abortSignal
An AbortSignal that cancels the model request. In a route, pass req.signal.
Masking
Replacing a real error message with a safe generic one before it leaves your server.

Step by step

01The whole route

This is the route from Topic 5.1 with every line explained. Read it top to bottom once before running the examples.

route handlerwhole filets
export async function POST(req: Request) {
  const { messages } = await req.json();            // 1. the browser's messages

  const result = streamText({                        // 3. returns at once, streams as it is read
    model,                                           //    which provider and model
    instructions: "You are Mitra.",                  //    your system prompt, set here only
    messages: await convertToModelMessages(messages),// 2. UI messages -> model messages
    abortSignal: req.signal,                         //    stop paying when the user leaves
  });

  return result.toUIMessageStreamResponse();         // 4. a Response with a streaming body
}

02Look at the bytes

The first live example calls the route with fetch and prints the chunk types in order. Message ids are random, so the example prints only types and text. You will see start, start-step, text-start, one text-delta per piece, text-end, finish-step, finish and [DONE].

Compare this with Topic 2.5. It is the same idea: small JSON events, a final event with the finish reason, and an end marker.

Look at the bytesdiagram
Rendering diagram…

03What the model actually receives

The second example prints the messages the stand-in model is given for a three-turn chat: first the system message made from instructions, then the conversation turns. It then sends a forged system message from the browser, a body that is not messages, and a body that is not JSON.

The route validates with safeValidateUIMessages (Topic 5.8) and drops any system message. The two bad bodies get 400, which is the correct answer for a bad request.

04Failures travel inside the stream

The third example makes the provider answer 429 (with a secret-looking message) before the first token, then makes a stream break after two words. In both cases the HTTP status is 200, the stream carries "errorText":"An error occurred.", and the real error appears only in the server log through onError.

05Stopping and the two callbacks

The fourth example reads three chunks and aborts. With abortSignal: req.signal the model request is cancelled and onAbort runs. With consumeSseStream: consumeStream the UI-level onEnd also runs, with isAborted: true and the partial text, which is what you need to save a half answer (Topic 5.8).

Try it yourself

  1. 1

    Remove consumeSseStream

    In the last example delete the line consumeSseStream: consumeStream, and run it.

    streamText onAbort still prints, but the UI-level onEnd line is gone. A route that saved the partial answer there would silently lose every stopped answer.

  2. 2

    Send a forged system message from the browser

    In the second example, delete the .filter(...) line and use checked.data as messages, then run it again.

    The "forged system message" call still shows 200, but the stream now holds an error chunk instead of a reply (the console prints AI_InvalidPromptError), because AI SDK 7 refuses system messages inside messages. The filter turns that failure into a safe, silent drop.

  3. 3

    Read the chunks yourself

    In the first example change the reply to "Hi 👋 नमस्ते" and run it.

    The deltas carry the emoji and the Hindi word as plain Unicode text. The SDK already handled the decoding and the multi-byte edge cases from Topic 2.3, which is why you get strings, not bytes.

Code & diagrams

The route, and the chunks it sends

Each text block has an id that its chunks share. The ids are random, so this example does not print them.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

200 text/event-stream
format header: v1
start
start-step
text-start
text-delta "Hello"
text-delta " Mitra."
text-end
finish-step
finish stop
[DONE]
What the model receives, and what a hostile request cannot do

The model sees the instructions first, then the turns. The forged system message was dropped before the call, so the model never saw it. The status is 200 for the forged message because the route chose to drop it and carry on.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

model sees: system="You are Mitra, the help assistant of Chai & Co." | user="Hi" | assistant="Hello, how can I help?" | user="Where is ORD-1042?"
three turns: 200
model sees: system="You are Mitra, the help assistant of Chai & Co." | user="Hi"
forged system message: 200
not messages: 400
not JSON: 400
Failures arrive inside a 200 response, with the real message kept on the server

The provider's message contains a secret-looking key. It reached your server log and went no further. Notice that onEnd ran for the answer and the broken stream, but not for the call that failed before its first token.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

(server) onEnd: stop
client sees: ok | HTTP 200 | text: "Orders ship in 1 to 2 working days." | error: none
(server) onError: 429 - Rate limit reached for key sk-live-DEMO in org-123
client sees: limit | HTTP 200 | text: "" | error: "An error occurred."
(server) onError: 500 - upstream connection reset
(server) onEnd: error
client sees: cut | HTTP 200 | text: "Orders ship in" | error: "An error occurred."
The browser leaves: abortSignal, onAbort and the UI onEnd

The browser side of stop is useChat's stop() in the next topic; here a plain AbortController plays that part.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

user pressed stop
(server) streamText onAbort
(server) onEnd: aborted = true | partial text saved: true
done

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget abortSignal

In the last example delete the line abortSignal: req.signal, and run it again.

terminal
$ The console, after the user pressed stop
── what you'll see ──
user pressed stop
(server) onEnd: aborted = false | partial text saved: false
done

Break #2

Trust the client's messages

In the second example, delete the .filter((m) => m.role !== "system") and use checked.data directly.

terminal
$ The console, for the forged system message
── what you'll see ──
AI_InvalidPromptError: Invalid prompt: System messages are not allowed in the prompt or messages fields. Use the instructions option instead.
forged system message: 200

Myth vs fact

Myth

If the response status is 200, the answer worked.

Fact

The stream's headers are sent before the model finishes, so a failure later is reported inside the stream as an error chunk. Read the chunks (or let useChat do it), not only the status.

Myth

I should catch errors from streamText with try/catch.

Fact

streamText returns before the work starts and does not throw. Errors go to onError and into the stream. generateText does throw, because it waits for the answer.

Myth

The browser needs to send the system prompt.

Fact

Never. It would let anyone rewrite your assistant's rules. Instructions belong on the server.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Add a maxOutputTokens limit to every route (Topic 1.5). It bounds the cost of one request and bounds how long a stream can run. When the limit hits, the finish reason is length.

  • ▸

    convertToModelMessages is where UI-only parts such as data parts and step markers are dropped or converted. If a message you saved looks different from what the model saw, this function is the place to check.

  • ▸

    Wrap onError so that it logs the error with a request id and returns a short user-safe string. Return that same id to the client in the error text so support can find the log line later (Topic 5.6).

  • ▸

    Streaming responses need a runtime that does not buffer. Some proxies and old hosting setups hold the whole body until it ends, which makes streaming look broken; the header x-accel-buffering: no, which the SDK already sets, helps with nginx.

Remember this

  1. 1

    The problem this solves: Your browser holds messages in one shape (with ids and parts for the screen). The model wants another shape (a role and some content). The answer must travel back as a stream the browser can rebuild into messages while it arrives, and errors have to travel in that same stream. This topic shows the four lines that do all of it, and what each one does inside.

  2. 2

    The route has four steps. (1) Read messages from the request body. (2) Convert them with await convertToModelMessages(messages): browser messages carry ids, parts and tool states, and the model needs plain role-and-content messages. It is async because converting can involve files. (3) Call streamText({ model, instructions, messages }); it returns at once and the work happens as the stream is read. (4) Return result.toUIMessageStreamResponse(), a standard Response with a streaming body.

  3. 3

    **instructions is your system prompt, and only the server sets it.** (AI SDK 7 calls it instructions; the older name system still works but is marked deprecated.) The browser must never decide it: anyone can edit a request. If a message with the role system arrives in messages, AI SDK 7 refuses it with AI_InvalidPromptError: System messages are not allowed in the prompt or messages fields. Use the instructions option instead. So validate the incoming messages and keep only user and assistant roles (defensive, and the live example shows it).

  4. 4

    **What toUIMessageStreamResponse() sends.** A Server-Sent Events body (Topic 2.4): lines data: {json} ended by a blank line, then data: [DONE]. The header x-vercel-ai-ui-message-stream: v1 names the format. The JSON chunks have a type: start, start-step, text-start, many text-delta (each with the new text in delta), text-end, finish-step, finish. Reasoning, tools, sources and your own data use more types (Topic 5.4). Each text block gets an id that its start, delta and end chunks share. This is the OpenAI chunk format of Topic 2.5 turned into a provider-neutral one, so the same browser code works for every provider.

  5. 5

    **Errors do not throw out of streamText.** The call returns before any network happens, so there is nothing to catch. A failure goes to the onError callback and into the stream as {"type":"error","errorText":"An error occurred."}. The HTTP status is still 200, because the headers were sent before the failure. By default the text is masked: the browser sees only 'An error occurred.', never the provider's message (which might contain your key or account details). Without your own onError, the SDK also prints the error to the server console. onEnd (the older name is onFinish) runs when the stream finishes, with the finish reason and usage; in our run it did not run when the call failed before the first token, so log failures in onError.

  6. 6

    Stopping. Pass abortSignal: req.signal so that when the browser disconnects, the request to the provider is cancelled and you stop paying for tokens nobody reads. Add onAbort for cleanup. A subtlety: when the client leaves, the response stream is cancelled and the UI-level onEnd may not run, unless you pass consumeSseStream: consumeStream to toUIMessageStreamResponse. The last live example shows the difference. On Vercel, request cancellation must also be switched on for the function (supportsCancellation in vercel.json), or the model may keep generating after the user pressed stop.

Explain it without notes

01

Walk through what a chat route does, line by line, from the request to the response.

02

Why does streamText not throw, and where do its errors go?

03

Why is the status of a failed stream still 200, and how does the browser learn about the failure?

04

What does abortSignal: req.signal do, and why might the UI-level onEnd not run when a user presses stop?

Practice

01

Add a size guard to the route in the first example: reject a request with more than 20 messages with a 400 and the text Too many messages.

02

Make the route in the third example answer in a friendly way when the stream fails: log the real error with onError and return nothing sensitive. What is the one option you would add to toUIMessageStreamResponse to change the text the browser sees? (Topic 5.6 builds it fully.)

Trade-offs

  • ↔

    Streaming the response gives fast first words but commits the HTTP status early, so failures must travel as chunks. A non-streamed generateText route can return a clean 429 or 500 status, at the cost of a long wait with nothing on screen.

  • ↔

    Passing abortSignal and consuming the stream on disconnect saves money and lets you store partial answers, but it ties your route to platform behaviour (Node versus edge, cancellation switches), so test it where you deploy.

Done when you can

  • I can write the four-step chat route from memory.

  • I can say why instructions is set only on the server and what happens to a forged system message.

  • I can list the chunk types of a simple text answer in order.

  • I can explain why a failed stream still has status 200 and where the error text comes from.

  • I can pass abortSignal and use consumeSseStream so a stopped answer is saved.