Skip to content
Chapter 106Lesson 1

streamText, generateText, and the route seam

Your first text-generation calls with the Vercel AI SDK, streamed through a guarded Next.js Route Handler.

The previous chapter did the setup: you decided the feature earns an LLM, capped its cost, and put the provider behind a named handle. None of it has generated a word yet. This lesson writes that first call and wraps it for production, from three pieces: two text-generation primitives, the messages array that feeds them, and the Next.js Route Handler that wraps every call with the auth and quota stack you already built.

One rule runs through all of it: every LLM call runs on the server. The browser sees the stream of text, never the provider key. The Route Handler is that boundary, the seam between client and provider.

One decision governs every call: does the output stream out word by word, or arrive all at once? The two primitives are the two answers.

generateText runs the model to completion and resolves a single Promise with the finished string on .text. streamText returns immediately and emits text deltas, the small chunks of the answer, which the handler pipes to the client as the model produces them.

This is a product decision, because of how a reader experiences the wait. A person reading a long answer doesn’t feel time to completion; they feel time to first token, the moment words start appearing. A four-second answer that begins streaming after 300 milliseconds feels fast; the same answer delivered whole after four seconds feels broken. So for anything a human reads, stream.

Reach for generateText when the result feeds code, not a person: a one-line classification that branches a Drizzle query, or a short tag the handler logs. No one watches the tokens arrive, so streaming buys nothing; you just want the complete string in a variable. Asking generateText for a long user-facing answer is the mistake to avoid: it strands the user on a spinner for the whole generation.

The same model call appears below as both shapes, one returning a stream and the other a string.

const result = streamText({
model: chatModel,
system: SYSTEM_PROMPT,
messages,
maxOutputTokens: 1000,
});

Streams deltas. The user sees the first token in well under a second, and the handler pipes the rest as it arrives. This is the shape for any surface a person reads.

Two details in that contrast carry over from the previous chapter. The model is always an imported handle, chatModel or fastModel, never an inline openai('gpt-5'); inlining a provider string is the exact abstraction leak you spent a lesson eliminating. And every call carries maxOutputTokens: a call without it isn’t a simpler example, it’s a cost-overrun bug waiting for a runaway generation to find it.

Both primitives take the conversation as a messages array, the contract every multi-turn call speaks.

Each entry pairs a role with content. The system role carries the instructions and persona; user and assistant entries alternate to record what the person asked and what the model replied. The model reads the array as the conversation so far and continues it.

An array with all three roles:

const messages = [
{ role: 'system', content: 'You answer questions about invoices for this org.' },
{ role: 'user', content: 'How much did Acme owe us last month?' },
{ role: 'assistant', content: 'Acme had two open invoices last month, totalling $4,200.' },
];

There are two message types, not one. A ModelMessage is the trimmed shape the model sees. A UIMessage is the full shape your app stores and renders, with a parts array, metadata, and tool calls the model has no use for.

At the seam the client sends UIMessage[], and the handler runs convertToModelMessages(messages) before streamText, dropping everything the model doesn’t read. Producing that rich shape on the client comes later in this chapter.

Plenty of calls carry no conversation. A one-shot backend call, such as classify this email or summarize this paragraph, has one input and no history. For those, pass prompt: string instead of messages, and the SDK wraps it in a single user message; a system prop still sits alongside.

Use prompt for single-turn, stateless calls inside a pipeline, and messages for anything user-facing or stateful. The two tabs above show the pairing: generateText passed prompt: emailBody, the streamText chat example passed messages.

The system prompt is the controller, not the conversation

Section titled “The system prompt is the controller, not the conversation”

The system message does a different job than the rest of the array, and the natural way to write it opens a security hole.

The system prompt sets the model’s role, answer constraints, refusal rules, and output format: trusted, code-authored text you write once and the model obeys on every turn. The user messages are the opposite, untrusted text from whoever is typing.

That distinction is the defense: the system prompt is the controller, user messages are data, so never splice user input into the system prompt as a string. The risk is prompt injection : once a user controls text in the instruction channel, they can rewrite the instructions.

The fix is structural, not a runtime check. Keep instructions in system and user text in user messages, and the two channels never touch, so there is nothing to sanitize. The system prompt is a module constant, or a value in lib/llm/prompts.ts, never templated from request data.

Here is the wrong shape, the one a beginner writes by reflex, next to the right one:

const system = `You are an assistant for ${userInput}.`;
const result = streamText({ model: chatModel, system, messages });

User text reaches the instruction channel. ${userInput} lands inside the controller, so a user who types ignore the above and … now rewrites the model’s instructions.

SYSTEM_PROMPT is SCREAMING_SNAKE_CASE and chatModel is camelCase: the casing marks the system prompt as a true compile-time constant baked into the build, while the model handle is regular state that happens to be frozen.

You have the three pieces: a primitive, a messages contract, and a system controller. Now wrap them into a handler you can ship.

This course defaults to Server Actions, but a streaming response can’t be one. The chat reply is a stream, so it lives in a Route Handler at app/api/chat/route.ts.

authedRoute(role, schema, fn) wraps the LLM logic in the stack you already built: it lifts authentication, the caller’s role, schema validation, and tenancy out of the handler body, and runs the rate-limit guard and the daily token quota.

Strip the wrapper away and the handler body is four moves:

  1. Parse the validated UIMessage[] from the request body.
  2. Convert it with convertToModelMessages(messages).
  3. Call streamText({ model, system, messages, maxOutputTokens }).
  4. Return result.toUIMessageStreamResponse().

toUIMessageStreamResponse() is the contract between the SDK and the client hook: it serializes the response into the structured parts the hook knows how to render. Return new Response(stream) instead and the client receives bytes it can’t parse, rendering garbage.

Here’s the whole file.

import { convertToModelMessages, streamText, type UIMessage } from 'ai';
import { z } from 'zod';
import { authedRoute } from '@/lib/api/authed-route';
import { chatModel } from '@/lib/llm/models';
import { SYSTEM_PROMPT } from '@/lib/llm/prompts';
const chatRequestSchema = z.object({
messages: z.array(z.custom<UIMessage>()),
});
export const POST = authedRoute('member', chatRequestSchema, async ({ messages }) => {
const result = streamText({
model: chatModel,
system: SYSTEM_PROMPT,
messages: convertToModelMessages(messages),
maxOutputTokens: 1000,
});
return result.toUIMessageStreamResponse();
});

Every LLM call site is a guarded Route Handler. The imports bring in the SDK primitives, the authedRoute wrapper, and handles to the model and system prompt rather than inlining them.

import { convertToModelMessages, streamText, type UIMessage } from 'ai';
import { z } from 'zod';
import { authedRoute } from '@/lib/api/authed-route';
import { chatModel } from '@/lib/llm/models';
import { SYSTEM_PROMPT } from '@/lib/llm/prompts';
const chatRequestSchema = z.object({
messages: z.array(z.custom<UIMessage>()),
});
export const POST = authedRoute('member', chatRequestSchema, async ({ messages }) => {
const result = streamText({
model: chatModel,
system: SYSTEM_PROMPT,
messages: convertToModelMessages(messages),
maxOutputTokens: 1000,
});
return result.toUIMessageStreamResponse();
});

The client sends UIMessage[], Zod-validated like any request body before the handler trusts it. authedRoute lifts auth, tenancy, the rate limit, and the daily quota out of the body, runs the parse, and hands you the typed messages.

import { convertToModelMessages, streamText, type UIMessage } from 'ai';
import { z } from 'zod';
import { authedRoute } from '@/lib/api/authed-route';
import { chatModel } from '@/lib/llm/models';
import { SYSTEM_PROMPT } from '@/lib/llm/prompts';
const chatRequestSchema = z.object({
messages: z.array(z.custom<UIMessage>()),
});
export const POST = authedRoute('member', chatRequestSchema, async ({ messages }) => {
const result = streamText({
model: chatModel,
system: SYSTEM_PROMPT,
messages: convertToModelMessages(messages),
maxOutputTokens: 1000,
});
return result.toUIMessageStreamResponse();
});

Convert the rich UI shape down to what the model reads, right before the call. The conversion is lossy: it drops metadata and parts.

import { convertToModelMessages, streamText, type UIMessage } from 'ai';
import { z } from 'zod';
import { authedRoute } from '@/lib/api/authed-route';
import { chatModel } from '@/lib/llm/models';
import { SYSTEM_PROMPT } from '@/lib/llm/prompts';
const chatRequestSchema = z.object({
messages: z.array(z.custom<UIMessage>()),
});
export const POST = authedRoute('member', chatRequestSchema, async ({ messages }) => {
const result = streamText({
model: chatModel,
system: SYSTEM_PROMPT,
messages: convertToModelMessages(messages),
maxOutputTokens: 1000,
});
return result.toUIMessageStreamResponse();
});

The call itself: the model handle, the system controller, the converted messages, and the mandatory cost cap. That’s the whole interaction with the model.

import { convertToModelMessages, streamText, type UIMessage } from 'ai';
import { z } from 'zod';
import { authedRoute } from '@/lib/api/authed-route';
import { chatModel } from '@/lib/llm/models';
import { SYSTEM_PROMPT } from '@/lib/llm/prompts';
const chatRequestSchema = z.object({
messages: z.array(z.custom<UIMessage>()),
});
export const POST = authedRoute('member', chatRequestSchema, async ({ messages }) => {
const result = streamText({
model: chatModel,
system: SYSTEM_PROMPT,
messages: convertToModelMessages(messages),
maxOutputTokens: 1000,
});
return result.toUIMessageStreamResponse();
});

Return the stream in the protocol the client hook reads. This line is the contract: return new Response(result) breaks it and the client renders garbage.

1 / 1

The handler returns the stream and exits, but two writes still have to happen after the model finishes: the per-user token counter ticks up, and the audit log records its llm.call.completed event.

Both primitives accept an onFinish callback. It fires once, after generation completes, with the final result: { text, usage, finishReason, response }. Post-call accounting can only live here, because this is the only code that runs after the tokens are counted.

The field you want is usage: { inputTokens, outputTokens, totalTokens }. Read it inside onFinish and call the helpers you already built, the audit write and the counter bump.

const result = streamText({
model: chatModel,
system: SYSTEM_PROMPT,
messages: convertToModelMessages(messages),
maxOutputTokens: 1000,
onFinish: ({ usage, finishReason }) => {
logLlmUsage({ orgId, userId, usage, finishReason });
incrementDailyTokens(userId, usage.totalTokens);
},
});

Write usage earlier, say right after you create the stream, and you record the wrong numbers: the call has not finished, so the output tokens do not exist yet. The cap stops a runaway call; onFinish keeps the accounting honest.

Every result also carries a finishReason, why the model stopped, and the UI has to react to it. The values are a fixed set:

  • 'stop': the model finished naturally. The normal case.
  • 'length': it hit maxOutputTokens and got cut off mid-thought.
  • 'content-filter': provider moderation tripped on the output.
  • 'tool-calls': the model wants to call a tool, the subject of the next chapter.
  • 'error': something failed during generation.
  • 'other': anything the provider didn’t classify.

Three of these need a user-facing message: 'length' shows “response was cut off” (and signals that the cap is too low for this surface), 'content-filter' shows the policy message instead of an empty box, and 'error' says something failed instead of ending mid-sentence.

You read finishReason server-side, in onFinish; rendering these states is a client-side job for later in this chapter.

When a user navigates away mid-answer, every token the model generates after they leave costs you money and buys nothing.

streamText accepts an abortSignal. Forward the request’s own signal, and the stream stops generating once the browser drops the connection:

export const POST = authedRoute('member', chatRequestSchema, async ({ messages }, request) => {
const result = streamText({
model: chatModel,
system: SYSTEM_PROMPT,
messages: convertToModelMessages(messages),
maxOutputTokens: 1000,
abortSignal: request.signal,
});
return result.toUIMessageStreamResponse();
});

On abort, onFinish does not fire by default, so the usage and audit write you set up above is skipped for cancelled calls. That is usually what you want, since the call never completed. To record one anyway, pass consumeStream in toUIMessageStreamResponse({ consumeStream, onFinish }), or handle the onAbort callback.

temperature controls output randomness: low values make the model pick the most likely continuation run after run, high values let it wander. For production, keep it low (roughly 0 to 0.3) for classification, summarization, and extraction, where downstream code parses the output and format stability matters more than novelty. Raise it only when creative variance is the feature you’re shipping, like a brainstorming surface or copy generator.

const result = streamText({
model: chatModel,
system: SYSTEM_PROMPT,
messages: convertToModelMessages(messages),
maxOutputTokens: 1000,
temperature: 0.2,
});

One request, traced end to end. The diagram makes one point: the LLM call is a guarded step inside a request, not a raw hit on a provider.

%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor { font-size: 17px !important; } .noteText, .noteText tspan { font-size: 16px !important; }'} }%%
sequenceDiagram
  participant C as Client (useChat)
  participant H as Route Handler
  participant G as Guards (auth + rate limit + quota)
  participant P as streamText / Provider
  participant A as Audit log
  C->>H: sendMessage — POST /api/chat (UIMessage[])
  rect rgba(129, 140, 248, 0.18)
  H->>G: auth, tenancy, rate limit, daily quota
  Note over G: a failed guard returns 4xx/429<br/>before a token is spent
  G-->>H: pass
  end
  H->>P: convertToModelMessages, then streamText
  P-->>H: stream text deltas
  H->>A: onFinish writes llm.call.completed (usage + cost)
  H-->>C: toUIMessageStreamResponse() — stream parts

Each box is a seam named in an earlier chapter. The client-side render of the streamed parts comes later in this chapter.

The provider call sits between a guard on the way in and the audit write on the way out, which is the difference between an LLM feature you can budget and one that surprises you on the invoice.

One check: which primitive to use, and why.

You need to classify each inbound support email into a status bucket ('open', 'pending', 'closed') so a Drizzle query can branch on the result. Which call fits, and why?

generateText with prompt and maxOutputTokens — the output is one short value that code consumes, so there’s no reader to stream to.
streamText — streaming makes the classification feel faster to the user.
generateText with prompt, and skip maxOutputTokens since the output is tiny anyway.

The official AI SDK references cover what this lesson treated lightly: the full onFinish payload, every call option, and the stream helpers.

Two references for the seams this lesson leaned on but didn’t unpack: why the call lives in a Route Handler, and the security model behind the controller-versus-data rule.