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)inapp/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.
convertToModelMessagesmakes 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
streamTextdoes not throw, this is where you log failures. - onEnd
- A callback that runs when the stream finishes, with the finish reason and token usage.
onFinishis the older name. - abortSignal
- An
AbortSignalthat cancels the model request. In a route, passreq.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.
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.
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
Remove consumeSseStream
In the last example delete the line
consumeSseStream: consumeStream,and run it.streamText onAbortstill prints, but the UI-levelonEndline is gone. A route that saved the partial answer there would silently lose every stopped answer. - 2
Send a forged system message from the browser
In the second example, delete the
.filter(...)line and usechecked.dataasmessages, 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 printsAI_InvalidPromptError), because AI SDK 7 refusessystemmessages insidemessages. The filter turns that failure into a safe, silent drop. - 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
Each text block has an id that its chunks share. The ids are random, so this example does not print them.
Preview
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]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
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: 400The 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
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 side of stop is useChat's stop() in the next topic; here a plain AbortController plays that part.
Preview
Console
Nothing logged yet.Expected console output
user pressed stop
(server) streamText onAbort
(server) onEnd: aborted = true | partial text saved: true
doneBreak 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.
Break #2
Trust the client's messages
In the second example, delete the .filter((m) => m.role !== "system") and use checked.data directly.
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
maxOutputTokenslimit 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 islength. - ▸
convertToModelMessagesis 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
onErrorso 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
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
The route has four steps. (1) Read
messagesfrom the request body. (2) Convert them withawait convertToModelMessages(messages): browser messages carry ids, parts and tool states, and the model needs plain role-and-content messages. It isasyncbecause converting can involve files. (3) CallstreamText({ model, instructions, messages }); it returns at once and the work happens as the stream is read. (4) Returnresult.toUIMessageStreamResponse(), a standardResponsewith a streaming body. - 3
**
instructionsis your system prompt, and only the server sets it.** (AI SDK 7 calls itinstructions; the older namesystemstill works but is marked deprecated.) The browser must never decide it: anyone can edit a request. If a message with the rolesystemarrives inmessages, AI SDK 7 refuses it withAI_InvalidPromptError: System messages are not allowed in the prompt or messages fields. Use the instructions option instead.So validate the incoming messages and keep onlyuserandassistantroles (defensive, and the live example shows it). - 4
**What
toUIMessageStreamResponse()sends.** A Server-Sent Events body (Topic 2.4): linesdata: {json}ended by a blank line, thendata: [DONE]. The headerx-vercel-ai-ui-message-stream: v1names the format. The JSON chunks have atype:start,start-step,text-start, manytext-delta(each with the new text indelta),text-end,finish-step,finish. Reasoning, tools, sources and your own data use more types (Topic 5.4). Each text block gets anidthat 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
**Errors do not throw out of
streamText.** The call returns before any network happens, so there is nothing to catch. A failure goes to theonErrorcallback and into the stream as{"type":"error","errorText":"An error occurred."}. The HTTP status is still200, 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 ownonError, the SDK also prints the error to the server console.onEnd(the older name isonFinish) 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 inonError. - 6
Stopping. Pass
abortSignal: req.signalso that when the browser disconnects, the request to the provider is cancelled and you stop paying for tokens nobody reads. AddonAbortfor cleanup. A subtlety: when the client leaves, the response stream is cancelled and the UI-levelonEndmay not run, unless you passconsumeSseStream: consumeStreamtotoUIMessageStreamResponse. The last live example shows the difference. On Vercel, request cancellation must also be switched on for the function (supportsCancellationinvercel.json), or the model may keep generating after the user pressed stop.
Explain it without notes
Walk through what a chat route does, line by line, from the request to the response.
Why does streamText not throw, and where do its errors go?
Why is the status of a failed stream still 200, and how does the browser learn about the failure?
What does abortSignal: req.signal do, and why might the UI-level onEnd not run when a user presses stop?
Practice
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.
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
generateTextroute can return a clean 429 or 500 status, at the cost of a long wait with nothing on screen. - ↔
Passing
abortSignaland 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
instructionsis set only on the server and what happens to a forgedsystemmessage.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
abortSignaland useconsumeSseStreamso a stopped answer is saved.