cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Building AI features in Next.js

Stream a model's answer from Route Handlers and Server Actions, build a chat with useChat, and keep keys, costs and untrusted output under control.

By the end, you can
  • 01
    Stream a model's answer to the pageCall a model with the AI SDK from a Route Handler or a Server Action, and show its answer piece by piece, or in a chat with useChat.
  • 02
    Keep the key and the bill under controlKeep the key in a server-only module, and check who asks, what they ask and how often before the model is called.
  • 03
    Treat the answer as untrustedShow the model's answer as text, and check access in your own code before a tool the model calls does anything.

A tea advisor that answers word by word

The tea shop wants an advisor: Asha types "Which tea helps me sleep?" and gets an answer from a language model, a program that writes text in reply to text, run by a company like OpenAI or Anthropic (the model provider). Your app sends it a request with a secret API key, and the provider bills you for every request. A model writes its answer a few letters at a time, and a long answer takes seconds. So the page shows it as it comes, piece by piece.

This lesson uses the AI SDK (the ai package on npm, by the company behind Next.js), version 7.0.157. It works the same way with many providers. Here is the advisor: a page, a Client Component that asks, and a Route Handler that calls the model. It's a recording of a real Next.js 16.4.3 project, built with next build, run with next start and used in real Chrome. The model in every recording is a stand-in: a small server that answers the way a real provider does, with fixed words, and accepts a fake key. No recording called a real model.

The tea advisor: a page, a Route Handler and a modelNext.js
app/_lib/api/advice/
app/api/advice/route.ts
import { createTextStreamResponse, streamText, toTextStream } from "ai";import { z } from "zod";import { allowCall, currentUserId } from "../../_lib/guard";import { model } from "../../_lib/model"; const Question = z.object({ question: z.string().trim().min(1).max(300) }); export async function POST(request: Request) {  const userId = await currentUserId();  if (!userId) return Response.json({ error: "Sign in to ask the advisor." }, { status: 401 });   const parsed = Question.safeParse(await request.json().catch(() => null));  if (!parsed.success) return Response.json({ error: "Ask a question of 1 to 300 characters." }, { status: 400 });   if (!allowCall(userId)) {    return Response.json({ error: "Three questions a minute. Try again soon." }, { status: 429 });  }   const result = streamText({    model,    instructions: "You are the tea shop's advisor. Answer in one or two sentences.",    prompt: parsed.data.question,    maxOutputTokens: 200,    abortSignal: request.signal,    onAbort: () => console.log(`${userId}'s request closed, so the model call stopped`),    onEnd: ({ totalUsage }) => {      console.log(`${userId} used ${totalUsage.inputTokens ?? "unknown"} input and ${totalUsage.outputTokens ?? "unknown"} output tokens`);    },  });  return createTextStreamResponse({ stream: toTextStream({ stream: result.stream }) });}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/Tea shop
Tea advisor
Which tea helps me sleep?Ask

Try chamomile: it has no caffeine and helps you relax.

Focus
the button “Ask”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. POST/api/advice200text/plain

app/api/advice/route.ts answers POST /api/advice. Every call to the model costs the shop money, and anyone on the internet can post to this address, so lines 9 to 17 check three things first: who is asking (a signed-in user), what they ask (a question of 1 to 300 characters), and how often (three questions a minute). Each refusal answers with an error and never reaches the model; the checks section takes them one by one. Then lines 19 to 29 call streamText from ai:

  • model: which model to ask. It comes from app/_lib/model.ts, which holds the provider's address and the key (the key section reads it).
  • instructions, line 21: what the shop tells the model before every question. Providers call this the system message.
  • prompt, line 22: Asha's question, as the check on line 12 read it.
  • maxOutputTokens and abortSignal, lines 23 and 24: how much the model may write, and when to stop.

streamText returns at once, before the model has written anything. Line 30 turns its stream into a response that sends the answer as plain text, piece by piece. Open the Steps tab: Asha, already signed in, typed her question, clicked Ask, and the page showed Try chamomile: it has no caffeine and helps you relax.

Recording-only fixtures

To make the recordings repeatable, the examples use two fixtures that a real app must never copy: the SESSION_SECRET in .env is fixed, and Asha's session cookie, which Chrome starts with, is signed to expire in 2100. In your app, generate the secret (openssl rand -base64 32), keep it out of the repository, and let sessions expire and be signed again, as in Authentication (7 days there). The MODEL_API_KEY is fake too.

Three windows from left to right: Asha's browser, the shop's server, and the model provider. The browser sends POST /api/advice with her question to the server. The server holds the key, MODEL_API_KEY, and sends POST /v1/chat/completions with the key to the provider. The provider streams pieces back: Try, chamomile, and so on. The server passes them on to the browser as plain text. The key never goes to the browser.browserWhich tea…?Try chamomile…your server/api/adviceMODEL_API_KEYstays heremodeltea-advisor-1POST+ keypieces flow back as the model writes them
The browser only talks to your Route Handler. Your server calls the model provider with the key, and passes each piece of the answer back as it arrives. The key stays on the server: the recording found it in none of the page's JavaScript.
Real-life analogyThe shop's phone line to a tea expert

The tea shop pays an expert by the minute to answer customers' questions over the phone. Asha never gets the expert's number or the shop's account: she asks at the counter. The assistant phones, and repeats each sentence as the expert says it, so Asha doesn't wait for the whole answer. Before phoning, the assistant checks that the question is a real one, and that nobody calls a hundred times an hour on the shop's bill.

In real life: Asha asks the shop assistant at the counter
In Next.js: The page posts the question to /api/advice
In real life: The assistant phones a tea expert, with the shop's account number
In Next.js: The Route Handler calls the model provider, with the key
In real life: The assistant repeats each sentence as the expert says it
In Next.js: The route streams each piece of the answer to the page
In real life: The account number never leaves the back office
In Next.js: The key stays in a server-only module
The main idea

Call the model on the server, and stream its answer to the page as it's written. The key, the checks on who asks and how often, and the limits on cost all stay on the server, in your Route Handler or Server Action. The answer that comes back is untrusted text.

Streaming the answer from a Route Handler

A provider streams its answer as server-sent events: lines of text that start with data: , each followed by a blank line. This is the start and the end of what the stand-in model streamed for Asha's question, in the format of OpenAI's API, which many providers also use:

What the stand-in model streamed (the first two events and the last two)Text
data: {"id":"chatcmpl-tea","object":"chat.completion.chunk","created":1793300000,"model":"tea-advisor-1","choices":[{"index":0,"delta":{"content":"Try "},"finish_reason":null}]}

data: {"id":"chatcmpl-tea","object":"chat.completion.chunk","created":1793300000,"model":"tea-advisor-1","choices":[{"index":0,"delta":{"content":"chamomile: "},"finish_reason":null}]}

…

data: {"id":"chatcmpl-tea","object":"chat.completion.chunk","created":1793300000,"model":"tea-advisor-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":31,"completion_tokens":6,"total_tokens":37}}

data: [DONE]

Each event holds one piece of the answer in choices[0].delta.content: Try , then chamomile: . The last ones say why the model stopped (finish_reason), how many tokens it read and wrote (a token is a piece of a word; providers bill by tokens), and [DONE]. You never parse these yourself: the provider package, @ai-sdk/openai-compatible here, turns them into result.stream, a stream of events your route can read as they come.

Line 30 of the route turns that stream into a response. AI SDK 7 has two pairs of helpers for it, and each row is one of them:

Turning streamText's result into a response
You writeWhat the response holdsWho reads it
createTextStreamResponse({ stream: toTextStream({ stream: result.stream }) })The answer as plain text, piece by piece (text/plain).Your own code reads it, like the advisor's fetch.
createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) })Server-sent events in the AI SDK's UI message format (text/event-stream).useChat reads it.
result.toTextStreamResponse(), result.toUIMessageStreamResponse()The same, from methods on the result.Older code. AI SDK 7 marks them deprecated.

In the Network tab, POST /api/advice, sent straight to the route with Asha's question, answered 200 with content-type: text/plain and the whole answer as its body. The recording keeps the body once it has all arrived; in the browser, it arrived in pieces. app/advisor.tsx reads those pieces:

app/advisor.tsx: add each piece as it arrivesTSX
"use client";

import { useState } from "react";

export function Advisor() {
  const [question, setQuestion] = useState("");
  const [answer, setAnswer] = useState("");

  async function ask() {
    setAnswer("");
    const response = await fetch("/api/advice", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ question }),
    });
    if (!response.ok) {
      setAnswer((await response.json()).error);
      return;
    }
    const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      setAnswer((old) => old + value);
    }
  }

  return (
    <>
      <input value={question} onChange={(event) => setQuestion(event.target.value)} aria-label="Your question" />
      <button onClick={ask}>Ask</button>
      <p>{answer}</p>
    </>
  );
}

Lines 11 to 15 post the question. Lines 16 to 19 show the error message when the route refuses, like a fourth question in a minute. Line 20 gets a reader for the body, decoded from bytes to text. The loop on lines 21 to 25 waits for each piece and adds it to answer, so React shows the answer growing. The Route Handlers lesson split its stream on new lines, because one read can hold half a line. Here that doesn't matter: the pieces are added in order, so however the text is cut, the page ends up with all of it.

And this is what the route sent the stand-in model, as the stand-in recorded it:

What the stand-in model received for Asha's questionJSON
{
  "model": "tea-advisor-1",
  "max_tokens": 200,
  "messages": [
    {
      "role": "system",
      "content": "You are the tea shop's advisor. Answer in one or two sentences."
    },
    {
      "role": "user",
      "content": "Which tea helps me sleep?"
    }
  ],
  "stream": true
}

Line 2 names the model, and line 3 caps the answer at 200 tokens. Lines 4 to 13 are the messages: the instructions as a system message, then Asha's question as a user message. Line 14 asks for a stream. The request also carried an authorization header with the fake key from .env: the server sent the key, never the page.

Calling a model from a Server Action

A Server Action can call a model too. For a short answer that a form waits for, like a product description for the shop's staff, generateText asks the model and waits for the whole answer:

A form whose Server Action writes a tea descriptionNext.js
app/_lib/describe/
app/describe/actions.ts
"use server"; import { generateText } from "ai";import { z } from "zod";import { allowCall, currentUserId } from "../_lib/guard";import { model } from "../_lib/model"; const Tea = z.string().trim().min(1).max(40); export async function describeTea(previous: string, formData: FormData) {  const userId = await currentUserId();  if (!userId) return "Sign in to write descriptions.";  const tea = Tea.safeParse(formData.get("tea"));  if (!tea.success) return "Name a tea of 1 to 40 characters.";  if (!allowCall(userId)) return "Three descriptions a minute. Try again soon.";   const { text } = await generateText({    model,    instructions: "You write the tea shop's product descriptions. One sentence.",    prompt: `Describe ${tea.data}.`,    maxOutputTokens: 100,  });  return text;}
Recorded from Next.js 16.4.3

POST /describe200

Sent with the request
cookie session=eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MSIsImlhdCI6MTc5MzMxODQwMCwiZXhwIjo0MTAyNDQ0ODAwfQ.sY9GVxj1-v3mOJvS4ev9s8MSmkxEorTL7e01C0TNkVQ
Typed into the form (sent with its hidden fields)
tea Mint tea
Response headers
cache-control no-cache, no-store, max-age=0, must-revalidate
content-type text/html; charset=utf-8
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-powered-by Next.js

The HTML that arrived, one element per line, with the code of its 12 scripts left out

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
    <script src="/_next/static/chunks/2gsxhio73malr.js" async=""></script>
    <script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
    <script src="/_next/static/chunks/0ooaq-girjaeh.js" async=""></script>
    <script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
    <script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
    <script src="/_next/static/chunks/02vzl2i0ae3y8.js" async=""></script>
    <title>Tea shop</title>
    <script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
  </head>
  <body>
    <div hidden="">
      <!--$-->
      <!--/$-->
    </div>
    <!--&-->
    <!--&-->
    <main>
      <h1>Describe a tea</h1>
      <!--F!-->
      <form action="" enctype="multipart/form-data" method="POST">
        <input type="hidden" name="$ACTION_REF_1">
        <input type="hidden" name="$ACTION_1:0" value="{&quot;id&quot;:&quot;60344250cce960b590c520fb6d4cdfe2d699c20365&quot;,&quot;bound&quot;:&quot;$@1&quot;}">
        <input type="hidden" name="$ACTION_1:1" value="[&quot;Mint tea is fresh and cool, with a sweet finish after every sip.&quot;]">
        <input type="hidden" name="$ACTION_KEY" value="k1e11587b2f37d02491b0759b426f1442">
        <input aria-label="Tea" name="tea" value="Mint tea">
        <button>Describe</button>
        <p>Mint tea is fresh and cool, with a sweet finish after every sip.</p>
      </form>
    </main>
    <!--$-->
    <!--/$-->
    <!--/&-->
    <!--/&-->
    <script id="_R_">…</script>
    <script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
    <script>…</script>
    <script>…</script>
    <script>…</script>
  </body>
</html>

In app/describe/actions.ts, lines 11 to 15 make the same three checks as the route, with the helpers from app/_lib/guard.ts: a signed-in user, a tea name of 1 to 40 characters (line 8), and three calls a minute. A Server Action is a public endpoint too, so it never skips them. Lines 17 to 22 call generateText, which returns once the model has finished. maxOutputTokens: 100 on line 21 caps the length of the answer. Line 23 returns the text, and useActionState in describe-form.tsx shows it (Forms teaches it). The recording posted the form the way a browser does before the page's JavaScript loads, and the Network tab shows the page that came back, with Mint tea is fresh and cool, with a sweet finish after every sip. in it. The form was sent with Asha's session cookie, as her browser would send it.

To stream from a Server Action, the AI SDK has streamable values in the @ai-sdk/rsc package. The action returns a value at once, and keeps updating it as the model writes:

A Server Action that streams the advisor's answerNext.js
app/_lib/ask/
app/ask/actions.ts
"use server"; import { createStreamableValue } from "@ai-sdk/rsc";import { streamText } from "ai";import { z } from "zod";import { allowCall, currentUserId } from "../_lib/guard";import { model } from "../_lib/model"; const Question = z.string().trim().min(1).max(300); export async function askAdvisor(question: unknown) {  const userId = await currentUserId();  if (!userId) return { error: "Sign in to ask the advisor." };  const parsed = Question.safeParse(question);  if (!parsed.success) return { error: "Ask a question of 1 to 300 characters." };  if (!allowCall(userId)) return { error: "Three questions a minute. Try again soon." };   const answer = createStreamableValue("");  (async () => {    try {      const result = streamText({ model, instructions: "You are the tea shop's advisor. Answer in one or two sentences.", prompt: parsed.data, maxOutputTokens: 200 });      for await (const part of result.stream) {        if (part.type === "text-delta") answer.update(part.text);        if (part.type === "error") throw part.error;      }      answer.done();    } catch (error) {      answer.error(error);    }  })();  return { answer: answer.value };}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/askTea shop
Tea advisor
Which tea helps me sleep?Ask

Try chamomile: it has no caffeine and helps you relax.

Focus
the button “Ask”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. POST/ask200text/x-component
    next-router-state-tree
    %5B%22%22%2C%7B%22children%22%3A%5B%22ask%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C514%5D%7D%2Cnull%2Cnull%2C530%5D
    next-action
    406cf07b384053891d8fe7a36b76a27f688ec69255

In app/ask/actions.ts, lines 12 to 16 make the same checks and return an error message for a refused call. Line 18 makes the streamable value. The function on lines 19 to 30 runs without being awaited. It reads result.stream: line 23 sends each piece of text with answer.update, and line 24 turns an error from the provider into a thrown error. Every path ends the value: line 26 says it's done, and if anything failed, line 28 passes the error on with answer.error, so the browser isn't left waiting. Line 31 returns answer.value right away. In app/ask/ask-form.tsx, readStreamableValue on line 19 waits for each update in the browser, and lines 22 and 23 show a message if the value ended with an error. In the Steps tab, the click made one request, POST /ask, answered with text/x-component: the Server Action's answer, which went on streaming the updates.

Streamable values are experimental

The AI SDK's docs mark @ai-sdk/rsc as experimental and recommend its UI hooks, like useChat with a Route Handler, for production. One limit they name: a stream from a Server Action can't be cancelled, so the Stop button below has no way to stop it. Use a Server Action with generateText for short answers, and a Route Handler for streams.

A chat with useChat

A chat keeps a conversation: Asha asks, the advisor answers, Asha asks again. useChat from @ai-sdk/react is a hook that keeps the messages, sends them to a Route Handler, and reads the streamed answer into a new message:

A chat page with useChatNext.js
app/_lib/api/chat/chat/
app/chat/chat.tsx
"use client"; import { useChat } from "@ai-sdk/react";import { useState } from "react"; export function Chat() {  const [input, setInput] = useState("");  const { messages, sendMessage, status } = useChat();   return (    <>      <ul>        {messages.map((message) => (          <li key={message.id}>            {message.role === "user" ? "You: " : "Advisor: "}            {message.parts.map((part) => (part.type === "text" ? part.text : "")).join("")}          </li>        ))}      </ul>      <p>{status === "ready" ? `${messages.length} messages` : "Thinking…"}</p>      <form        onSubmit={(event) => {          event.preventDefault();          sendMessage({ text: input });          setInput("");        }}      >        <input value={input} onChange={(event) => setInput(event.target.value)} aria-label="Message" />        <button disabled={status !== "ready"}>Send</button>      </form>    </>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/chatTea shop
Ask the tea advisor
  • You: Which tea helps me sleep?
  • Advisor: Try chamomile: it has no caffeine and helps you relax.
  • You: And which one has no caffeine?
  • Advisor: Try chamomile: it has no caffeine and helps you relax.

4 messages

Send
Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. POST/api/chat200text/event-stream

In app/chat/chat.tsx:

  • Line 8 calls useChat(). With no options it posts to /api/chat. It gives back messages, sendMessage, and status, which is "ready" when no answer is on its way.
  • Lines 13 to 18 show each message. A message has parts; this page shows the text ones, after You: or Advisor: .
  • Line 24 sends what Asha typed. Line 20 shows how many messages there are once the answer is done, and line 29 turns Send off while one is on its way.

The route behind it makes the same checks as the advisor's, and checks the conversation too:

app/api/chat/route.ts: checks, then a UI message streamTypeScript
import { convertToModelMessages, createUIMessageStreamResponse, streamText, toUIMessageStream, validateUIMessages } from "ai";
import { allowCall, currentUserId } from "../../_lib/guard";
import { model } from "../../_lib/model";

export async function POST(request: Request) {
  const userId = await currentUserId();
  if (!userId) return Response.json({ error: "Sign in to chat with the advisor." }, { status: 401 });

  const body = await request.json().catch(() => null);
  const messages = await validateUIMessages({ messages: body?.messages }).catch(() => null);
  if (!messages || messages.length > 20 || JSON.stringify(messages).length > 8000) {
    return Response.json({ error: "Send at most 20 messages, 8,000 characters in all." }, { status: 400 });
  }

  if (!allowCall(userId)) {
    return Response.json({ error: "Three questions a minute. Try again soon." }, { status: 429 });
  }

  const result = streamText({
    model,
    instructions: "You are the tea shop's advisor. Answer in one or two sentences.",
    messages: await convertToModelMessages(messages),
    maxOutputTokens: 200,
    abortSignal: request.signal,
  });
  return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) });
}

Lines 6 and 7 check who is asking. The messages come from the browser, so they're input like any other, and a browser can send any history, even an assistant message the model never wrote. Line 10 checks their shape with validateUIMessages, and line 11 refuses more than 20 messages or more than 8,000 characters in all. Lines 15 to 17 count the call. Line 22 turns the messages into the model's format with convertToModelMessages, lines 23 and 24 cap the answer and pass the signal, and line 26 answers with createUIMessageStreamResponse. In the Steps tab, Asha asked twice, and the page counted 4 messages. The stand-in always gives the same answer, which happens to fit both questions. In the Network tab, POST /api/chat shows the stream useChat reads: server-sent events like {"type":"text-delta","id":"txt-0","delta":"Try "}, and the header x-vercel-ai-ui-message-stream: v1.

Now look at what the route sent the model for Asha's second question:

What the stand-in model received for the second questionJSON
{
  "model": "tea-advisor-1",
  "max_tokens": 200,
  "messages": [
    {
      "role": "system",
      "content": "You are the tea shop's advisor. Answer in one or two sentences."
    },
    {
      "role": "user",
      "content": "Which tea helps me sleep?"
    },
    {
      "role": "assistant",
      "content": "Try chamomile: it has no caffeine and helps you relax."
    },
    {
      "role": "user",
      "content": "And which one has no caffeine?"
    }
  ],
  "stream": true
}

Four messages: the instructions, her first question, the first answer, and her new question. The model keeps nothing between calls in this setup, so every call carries the whole conversation, and the provider reads, and bills, all of it again.

Two rows of cards, one for each call the chat route made to the model. The first call sends two messages: the instructions and Asha's question. The second call sends four: the instructions, her first question, the advisor's answer, and her new question.first question: 2 messagesinstructionsWhich tea helps…?second question: 4 messagesinstructionsWhich tea helps…?Try chamomile…And which one…?older messages go again with every call
What the route sent the model for each of Asha's questions, from the recording. The model sees only what each call sends, so the second call carries the whole conversation: four messages instead of two. Every message is billed again, on every call.

That's why a long chat costs more for every new question. Real chats cap how many messages they send, or replace old ones with a short summary; line 11's cap also stops one chat from growing without end.

Keeping the API key on the server

Anyone who has the key can call the model on your account. So the key lives in one module, which only the server may load:

app/_lib/model.ts: the provider and its keyTypeScript
import "server-only";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";

// The model provider: its address and its key come from the server's environment.
const provider = createOpenAICompatible({
  name: "tea-model",
  baseURL: `${process.env.MODEL_API_URL}/v1`,
  apiKey: process.env.MODEL_API_KEY,
});

export const model = provider("tea-advisor-1");

Line 1, import "server-only", marks the module as server code (Server-only code and environment variables teaches it). Lines 5 to 9 make the provider: line 7 reads its address from MODEL_API_URL (in the recordings, the stand-in's address), and line 8 reads the key from MODEL_API_KEY, set in .env. Neither name starts with NEXT_PUBLIC_, so Next.js doesn't inline their values into the JavaScript it builds for the browser. That prefix only decides what gets inlined: it isn't access control. A key without it still reaches the browser the moment a component renders it or passes it to a Client Component. So the key is read in this one server-only module, used only to call the provider, and never rendered, returned or passed on. Line 11 picks the model. With a real provider, you'd use its own package, like @ai-sdk/openai, or its address here.

The advisor's recording looked for the key, and for the instructions, in every JavaScript file the page loads: neither was in any of them. Here is what happens when a Client Component tries to call the model itself:

The advisor calling the model from the browser (an error on purpose)Next.js
app/_lib/api/advice/
app/advisor.tsx
"use client"; import { streamText } from "ai";import { useState } from "react";import { model } from "./_lib/model"; export function Advisor() {  const [question, setQuestion] = useState("");  const [answer, setAnswer] = useState("");   async function ask() {    setAnswer("");    const result = streamText({ model, prompt: question });    for await (const piece of result.textStream) setAnswer((old) => old + piece);  }   return (    <>      <input value={question} onChange={(event) => setQuestion(event.target.value)} aria-label="Your question" />      <button onClick={ask}>Ask</button>      <p>{answer}</p>    </>  );}
next build failed · Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 2 errors:./app/_lib/model.ts:1:1Error: You're importing a module that depends on "server-only" into a React Client Component module. This API is only available in Server Components but one of its parents is marked with "use client", so this module is also a Client Component.    Learn more: https://nextjs.org/docs/app/building-your-application/rendering> 1 | import "server-only";    | ^^^^^^^^^^^^^^^^^^^^^  2 | import { createOpenAICompatible } from "@ai-sdk/openai-compatible";  3 |  4 | // The model provider: its address and its key come from the server's environment. Ecmascript file had an error Import traces:  App Route:    ./app/_lib/model.ts    ./app/api/advice/route.ts   Client Component Browser:    ./app/_lib/model.ts [Client Component Browser]    ./app/advisor.tsx [Client Component Browser]    ./app/advisor.tsx [Server Component]    ./app/page.tsx [Server Component]   Client Component SSR:    ./app/_lib/model.ts [Client Component SSR]    ./app/advisor.tsx [Client Component SSR]    ./app/advisor.tsx [Server Component]    ./app/page.tsx [Server Component] ./app/_lib/model.ts:1:1Error: 'server-only' cannot be imported from a Client Component module> 1 | import "server-only";    | ^^^^^^^^^^^^^^^^^^^^^  2 | import { createOpenAICompatible } from "@ai-sdk/openai-compatible";  3 |  4 | // The model provider: its address and its key come from the server's environment. It should only be used from a Server Component. Import traces:  App Route:    ./app/_lib/model.ts    ./app/api/advice/route.ts   Client Component Browser:    ./app/_lib/model.ts [Client Component Browser]    ./app/advisor.tsx [Client Component Browser]    ./app/advisor.tsx [Server Component]    ./app/page.tsx [Server Component]   Client Component SSR:    ./app/_lib/model.ts [Client Component SSR]    ./app/advisor.tsx [Client Component SSR]    ./app/advisor.tsx [Server Component]    ./app/page.tsx [Server Component]     at ignore-listed frames

Line 5 of this app/advisor.tsx imports the model module, and line 13 calls streamText in the browser. The build stops: You're importing a module that depends on "server-only" into a React Client Component module. Without line 1 of the model module, it would have built, and the module's code would have gone to the browser. Each row is one place a key could be:

Where the model's key may be
WhereIs the key safe there?Why
.env (or the host's settings) as MODEL_API_KEYYes, if only server-only code reads it.Without NEXT_PUBLIC_, Next.js doesn't inline it into the browser's code. The prefix isn't access control: rendering the value or passing it to a Client Component still leaks it.
app/_lib/model.ts, which starts with import "server-only"Yes.A Client Component that imports it stops the build, and the key is only used to call the provider.
A variable named NEXT_PUBLIC_MODEL_API_KEYNo.Next.js writes its value into the JavaScript that goes to the browser.
A Client Component, or anything it importsNo.Its code goes to the browser, where anyone can read it.
Anything a component renders, returns, or passes as a propNo.Whatever a Server Component renders or passes to a Client Component is sent to the browser.

Checks, limits and costs

A Route Handler is a public address: anyone can post to /api/advice, from any program, as often as they like, and every call costs the shop money. So before the model is called, the advisor's route checks who is asking, what they ask, and how often. This recording sends it six requests, one after the other:

The advice route, with checks before the modelNext.js
app/_lib/api/advice/
app/api/advice/route.ts
import { createTextStreamResponse, streamText, toTextStream } from "ai";import { z } from "zod";import { allowCall, currentUserId } from "../../_lib/guard";import { model } from "../../_lib/model"; const Question = z.object({ question: z.string().trim().min(1).max(300) }); export async function POST(request: Request) {  const userId = await currentUserId();  if (!userId) return Response.json({ error: "Sign in to ask the advisor." }, { status: 401 });   const parsed = Question.safeParse(await request.json().catch(() => null));  if (!parsed.success) return Response.json({ error: "Ask a question of 1 to 300 characters." }, { status: 400 });   if (!allowCall(userId)) {    return Response.json({ error: "Three questions a minute. Try again soon." }, { status: 429 });  }   const result = streamText({    model,    instructions: "You are the tea shop's advisor. Answer in one or two sentences.",    prompt: parsed.data.question,    maxOutputTokens: 200,    abortSignal: request.signal,    onAbort: () => console.log(`${userId}'s request closed, so the model call stopped`),    onEnd: ({ totalUsage }) => {      console.log(`${userId} used ${totalUsage.inputTokens ?? "unknown"} input and ${totalUsage.outputTokens ?? "unknown"} output tokens`);    },  });  return createTextStreamResponse({ stream: toTextStream({ stream: result.stream }) });}
Recorded from Next.js 16.4.3

POST /api/advice401

Sent with the request
content-type application/json

The body sent with the request

{"question":"Which tea helps me sleep?"}
Response headers
content-type application/json
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch

The body that arrived, as it arrived

{"error":"Sign in to ask the advisor."}
  • Who is asking. Lines 9 and 10 call currentUserId from app/_lib/guard.ts, which reads the signed session cookie from Authentication (app/_lib/session.ts is that lesson's file). No valid session: 401.
  • What they ask. Line 6 says what a question is: text of 1 to 300 characters once spaces are trimmed. Line 12 checks the body with Zod, and line 13 answers 400 for anything else.
  • How often. Lines 15 to 17 allow three model calls a minute for each user: allowCall in app/_lib/guard.ts uses allowTry from Securing Server Actions. Its counter lives in this one server's memory; with several servers or serverless functions, keep it in a shared store like Redis.
  • How much. Line 23, maxOutputTokens: 200, caps how many tokens the model may write for one answer.
Four cards from left to right: session, a question of 1 to 300 characters, three a minute, and finally the model with at most 200 output tokens. Under each of the first three, a refused request stops with 401, 400 or 429. Under the model: stops when the visitor leaves.session?4011 to 300 chars?4003 a minute?429model, 200 tokensstops if Asha leavesrefused requests never reach the model, and cost nothing
The advisor's route, in order. A request without a session stops at 401, an empty question at 400, and a fourth question in a minute at 429. Only a request that passes all three reaches the model, with a cap on its answer and the request's signal.

In the Network tab, the six requests answered 401, 400, 200, 200, 200 and 429. The stand-in model received only three calls, each with "max_tokens":200 in its body: the refused requests never reached it, so they cost nothing. Lines 26 to 28 print what each call used, from totalUsage, once the answer has finished; the Terminal shows u1 used 31 input and 6 output tokens three times. AI SDK 7 types these counts as optional: a provider may leave them out, so line 27 prints unknown instead of a number, and code that saves them per user must handle a missing count.

Stopping the call when the visitor leaves

Line 24 passes request.signal to streamText. A signal (an AbortSignal) is an object that says when something should stop. With next start, Next.js aborts a Route Handler's request.signal when the visitor's request closes: they closed the tab, or clicked Stop. This advisor has a Stop button, and the stand-in model never finishes its answer, the way a long answer would still be on its way:

An advisor with a Stop buttonNext.js
app/_lib/api/advice/
app/api/advice/route.ts
import { createTextStreamResponse, streamText, toTextStream } from "ai";import { z } from "zod";import { allowCall, currentUserId } from "../../_lib/guard";import { model } from "../../_lib/model"; const Question = z.object({ question: z.string().trim().min(1).max(300) }); export async function POST(request: Request) {  const userId = await currentUserId();  if (!userId) return Response.json({ error: "Sign in to ask the advisor." }, { status: 401 });   const parsed = Question.safeParse(await request.json().catch(() => null));  if (!parsed.success) return Response.json({ error: "Ask a question of 1 to 300 characters." }, { status: 400 });   if (!allowCall(userId)) {    return Response.json({ error: "Three questions a minute. Try again soon." }, { status: 429 });  }   const result = streamText({    model,    instructions: "You are the tea shop's advisor. Answer in one or two sentences.",    prompt: parsed.data.question,    maxOutputTokens: 200,    abortSignal: request.signal,    onAbort: () => console.log(`${userId}'s request closed, so the model call stopped`),    onEnd: ({ totalUsage }) => {      console.log(`${userId} used ${totalUsage.inputTokens ?? "unknown"} input and ${totalUsage.outputTokens ?? "unknown"} output tokens`);    },  });  return createTextStreamResponse({ stream: toTextStream({ stream: result.stream }) });}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/Tea shop
Tea advisor
Which tea helps me sleep?AskStop

Try chamomile

Stopped

Focus
the button “Stop”
Scrolled
0 px down

No requests during this step: the page already had what it needed

It's the same route: line 24 passes the signal, and line 25 logs when it fires. In app/advisor.tsx, the Stop button calls abort() on the AbortController whose signal the fetch got. In the Steps tab, the page showed Try chamomile, then Stopped. The Terminal shows u1's request closed, so the model call stopped, and the stand-in model recorded that its request was closed before the answer had ended. Step through it:

Playground: who does what when Asha clicks Stop
Who does whatAsha clicks Stop
What the visitor seesafter step 1
tea-shop.example/

Tea advisor

Which tea helps me sleep? Ask Stop

React is running: the page responds to clicks.

The page isn't complete yet.

Step 1 of 6
Browser: Asha clicks Ask

ask() makes an AbortController and sends POST /api/advice with its signal.

Each step is one thing that happens, in order. The screens are the pages recorded in real Chrome for the example above; the last step is what the stand-in model recorded. Times aren't to scale. Reset goes back to the first step.

Without line 24, the route would go on reading words nobody will see. Whether the provider stops writing, and what it bills for an answer cut short, depends on the provider. One limit to know: a stream from a Server Action can't be cancelled this way.

The model's answer is untrusted

A model follows text, and it can't reliably tell your instructions from text written by someone else. Prompt injection is text that tells the model to break its instructions: a user typing "ignore your rules and…", or a sentence hidden in a review, a web page or a file that the model is asked to read. No instruction you write stops it for sure. So treat the answer like any other input from outside: it may hold anything.

The first place that matters is the page. Here the stand-in model answers with text that holds an HTML tag, as an injected answer might. Before you pick a version, guess what each one shows.

Playground: what happens to the model's answer on the page?
The tea advisor, with this version of app/advisor.tsxNext.js
app/_lib/api/advice/
app/advisor.tsx
"use client"; import { useState } from "react"; export function Advisor() {  const [question, setQuestion] = useState("");  const [answer, setAnswer] = useState("");   async function ask() {    setAnswer("");    const response = await fetch("/api/advice", {      method: "POST",      headers: { "Content-Type": "application/json" },      body: JSON.stringify({ question }),    });    if (!response.ok) {      setAnswer((await response.json()).error);      return;    }    const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();    while (true) {      const { value, done } = await reader.read();      if (done) break;      setAnswer((old) => old + value);    }  }   return (    <>      <input value={question} onChange={(event) => setQuestion(event.target.value)} aria-label="Your question" />      <button onClick={ask}>Ask</button>      <p>{answer}</p>    </>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/Tea shop
Tea advisor
Which tea helps me sleep?Ask

Chamomile is calming. <img src="x" onerror="document.body.append('Injected code ran')">

Focus
the button “Ask”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. POST/api/advice200text/plain
Try it yourself
Shown as text

Line 32 puts the answer in <p> as text. React escapes it, so the <img> tag shows as characters on the page, and nothing runs.

Both versions were built with next build from Next.js 16.4.3 and used in real Chrome, with a stand-in model whose answer holds an <img> tag. Steps shows the page after Ask, and the errors Chrome's console printed. Reset goes back to the first version.

Shown as text, the tag is only characters on the page: React escapes text it puts in the page, so nothing runs. Used as HTML, Chrome built a real <img>, its address x failed with a 404, and the code in its onerror ran and wrote Injected code ran. Injected code can do anything the page's own code can, like send the visitor's data elsewhere. Keep answers as text. If you show the answer as Markdown, the renderer must:

  • keep raw HTML in the Markdown turned off, so a tag stays text;
  • allow only safe link and image addresses, like https: and mailto:, and never javascript:;
  • sanitise the HTML it produces whenever any step does produce HTML, since a plugin can add some.

A Content Security Policy is a second wall behind that.

The second place is tools: functions you let the model call, like looking up an order. The model chooses the arguments, so treat them as untrusted too, and check access in your own code, as for any request:

A tool the model may call, with the check in your code (a sketch, not recorded)TypeScript
import { streamText, tool, type ModelMessage } from "ai";
import { z } from "zod";
import { getCurrentUser } from "@/lib/dal";
import { findOrder } from "@/lib/db";
import { model } from "@/app/_lib/model";

export async function answerAboutOrders(messages: ModelMessage[]) {
  // Who is asking comes from the session, on the server: never from the model.
  const user = await getCurrentUser();
  if (!user) throw new Error("Sign in first");
  return streamText({
    model,
    instructions: "You answer questions about the customer's orders.",
    messages,
    maxOutputTokens: 200,
    tools: {
      orderStatus: tool({
        description: "Look up one of the signed-in customer's orders",
        inputSchema: z.object({ orderId: z.number().int() }),
        // The model chose orderId, so check that the order is this user's.
        execute: async ({ orderId }) => {
          const order = await findOrder(orderId);
          return order?.userId === user.id ? order.status : "No such order";
        },
      }),
    },
  });
}

Line 9 reads the signed-in user on the server, with getCurrentUser from the data access layer, before the model runs, and line 10 stops without one. Lines 17 to 25 define the tool: a description the model reads, the shape of its input (checked with Zod), and execute, your code. The model only chooses orderId; line 23 returns the order's status only if the order's userId is the signed-in user's. The model never chooses whose data it reads. Give tools only what the user may do anyway, and ask the user to confirm anything that can't be undone. One more guard: by default, the AI SDK refuses system messages inside prompt or messages, so your rules belong in instructions. The option allowSystemInMessages: true turns that off; use it only for a history your own server wrote, never one a browser sent.

Checking the advisor before it ships

Before the advisor goes live, go through its choices. Sort each one: safe to ship, or fix it first?

Safe to ship, or fix it first?
  • The model module starts with import "server-only".
  • The key is in NEXT_PUBLIC_MODEL_API_KEY.
  • The route counts each user's calls before it calls the model.
  • Anyone on the internet can call /api/advice.
  • Each call sets maxOutputTokens.
  • The page shows the answer with dangerouslySetInnerHTML.
  • A tool that cancels an order checks the order is the signed-in user's.
  • The route passes request.signal to streamText.
Try it yourself
0 of 8 correct

Sort each of the tea advisor's choices.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "The browser can call the model directly with a public key." No. A NEXT_PUBLIC_ key is in the browser's code, where anyone can copy it. Call the model from the server.
  • "Good instructions make the answer safe." No. Prompt injection can make the model ignore them. Show the answer as text, and check access in tools.
  • "The model remembers the chat." No. In the recorded chat, each call sent the whole conversation, and the provider read all of it again.
  • "Closing the tab stops the model call." Only if the route passes request.signal to streamText. Otherwise the server keeps reading the answer.
  • "A rate limit after the call is as good as one before." No. Every call that reaches the model is billed. Count first: the refused fourth question never reached the stand-in.
  • "Streaming needs Server Actions." No. A Route Handler streams, and the AI SDK recommends that for production; streams from Server Actions are experimental and can't be cancelled.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upFind the key

These are the advisor's files. Which one reads MODEL_API_KEY?

Starter codeText
.env
app/_lib/model.ts
app/advisor.tsx
app/api/advice/route.ts
app/layout.tsx
app/page.tsx

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upWhat the model is allowed to write

    What max_tokens does the model provider receive from this route?

    Starter codeTypeScript
    import { createTextStreamResponse, streamText, toTextStream } from "ai";
    import { z } from "zod";
    import { allowCall, currentUserId } from "../../_lib/guard";
    import { model } from "../../_lib/model";
    
    const Question = z.object({ question: z.string().trim().min(1).max(300) });
    
    export async function POST(request: Request) {
      const userId = await currentUserId();
      if (!userId) return Response.json({ error: "Sign in to ask the advisor." }, { status: 401 });
    
      const parsed = Question.safeParse(await request.json().catch(() => null));
      if (!parsed.success) return Response.json({ error: "Ask a question of 1 to 300 characters." }, { status: 400 });
    
      if (!allowCall(userId)) {
        return Response.json({ error: "Three questions a minute. Try again soon." }, { status: 429 });
      }
    
      const result = streamText({
        model,
        instructions: "You are the tea shop's advisor. Answer in one or two sentences.",
        prompt: parsed.data.question,
        maxOutputTokens: 200,
        abortSignal: request.signal,
        onAbort: () => console.log(`${userId}'s request closed, so the model call stopped`),
        onEnd: ({ totalUsage }) => {
          console.log(`${userId} used ${totalUsage.inputTokens ?? "unknown"} input and ${totalUsage.outputTokens ?? "unknown"} output tokens`);
        },
      });
      return createTextStreamResponse({ stream: toTextStream({ stream: result.stream }) });
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeCount the model calls

      These six requests reach the advisor's route, one after the other, within a minute. How many calls reach the model?

      Starter codeText
      1. No session cookie, a good question.
      2. Asha's session, the question "   ".
      3. Asha's session, "Which tea helps me sleep?"
      4. Asha's session, the same question again.
      5. Asha's session, the same question a third time.
      6. Asha's session, the same question a fourth time.

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeShow the answer safely

        This advisor ran code from the model's answer. Which line has to change, and to what?

        Starter codeTSX
        "use client";
        
        import { useState } from "react";
        
        export function Advisor() {
          const [question, setQuestion] = useState("");
          const [answer, setAnswer] = useState("");
        
          async function ask() {
            setAnswer("");
            const response = await fetch("/api/advice", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ question }),
            });
            if (!response.ok) {
              setAnswer((await response.json()).error);
              return;
            }
            const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
            while (true) {
              const { value, done } = await reader.read();
              if (done) break;
              setAnswer((old) => old + value);
            }
          }
        
          return (
            <>
              <input value={question} onChange={(event) => setQuestion(event.target.value)} aria-label="Your question" />
              <button onClick={ask}>Ask</button>
              <p dangerouslySetInnerHTML={{ __html: answer }} />
            </>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeCall the model from the browser?

          Someone tries to skip the Route Handler and call the model in the Client Component. The build stops. Which import stops it?

          Starter codeTSX
          "use client";
          
          import { streamText } from "ai";
          import { useState } from "react";
          import { model } from "./_lib/model";
          
          export function Advisor() {
            const [question, setQuestion] = useState("");
            const [answer, setAnswer] = useState("");
          
            async function ask() {
              setAnswer("");
              const result = streamText({ model, prompt: question });
              for await (const piece of result.textStream) setAnswer((old) => old + piece);
            }
          
            return (
              <>
                <input value={question} onChange={(event) => setQuestion(event.target.value)} aria-label="Your question" />
                <button onClick={ask}>Ask</button>
                <p>{answer}</p>
              </>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeOrder the checks

            In what order should the advisor's route do these four steps?

            Starter codeText
            A. Call the model with maxOutputTokens: 200.
            B. Check the question is 1 to 300 characters.
            C. Check who is asking.
            D. Count this user's calls this minute.

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask where the code runs, what reaches the model, and what the page does with the answer.

              The AI features quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does this Route Handler send back while the model is still writing?

                Read the code, then predictTypeScript
                import { createTextStreamResponse, streamText, toTextStream } from "ai";
                import { z } from "zod";
                import { allowCall, currentUserId } from "../../_lib/guard";
                import { model } from "../../_lib/model";
                
                const Question = z.object({ question: z.string().trim().min(1).max(300) });
                
                export async function POST(request: Request) {
                  const userId = await currentUserId();
                  if (!userId) return Response.json({ error: "Sign in to ask the advisor." }, { status: 401 });
                
                  const parsed = Question.safeParse(await request.json().catch(() => null));
                  if (!parsed.success) return Response.json({ error: "Ask a question of 1 to 300 characters." }, { status: 400 });
                
                  if (!allowCall(userId)) {
                    return Response.json({ error: "Three questions a minute. Try again soon." }, { status: 429 });
                  }
                
                  const result = streamText({
                    model,
                    instructions: "You are the tea shop's advisor. Answer in one or two sentences.",
                    prompt: parsed.data.question,
                    maxOutputTokens: 200,
                    abortSignal: request.signal,
                    onAbort: () => console.log(`${userId}'s request closed, so the model call stopped`),
                    onEnd: ({ totalUsage }) => {
                      console.log(`${userId} used ${totalUsage.inputTokens ?? "unknown"} input and ${totalUsage.outputTokens ?? "unknown"} output tokens`);
                    },
                  });
                  return createTextStreamResponse({ stream: toTextStream({ stream: result.stream }) });
                }
                

                Choose an answer to see the explanation.

              2. Question 2 of 7Where should the model provider's key be read?

                Choose an answer to see the explanation.

              3. Question 3 of 7Asha asks the chat a second question. What does the route send the model for it?

                Choose an answer to see the explanation.

              4. Question 4 of 7In this route, why does allowCall come before streamText?

                Read the code, then predictTypeScript
                import { createTextStreamResponse, streamText, toTextStream } from "ai";
                import { z } from "zod";
                import { allowCall, currentUserId } from "../../_lib/guard";
                import { model } from "../../_lib/model";
                
                const Question = z.object({ question: z.string().trim().min(1).max(300) });
                
                export async function POST(request: Request) {
                  const userId = await currentUserId();
                  if (!userId) return Response.json({ error: "Sign in to ask the advisor." }, { status: 401 });
                
                  const parsed = Question.safeParse(await request.json().catch(() => null));
                  if (!parsed.success) return Response.json({ error: "Ask a question of 1 to 300 characters." }, { status: 400 });
                
                  if (!allowCall(userId)) {
                    return Response.json({ error: "Three questions a minute. Try again soon." }, { status: 429 });
                  }
                
                  const result = streamText({
                    model,
                    instructions: "You are the tea shop's advisor. Answer in one or two sentences.",
                    prompt: parsed.data.question,
                    maxOutputTokens: 200,
                    abortSignal: request.signal,
                    onAbort: () => console.log(`${userId}'s request closed, so the model call stopped`),
                    onEnd: ({ totalUsage }) => {
                      console.log(`${userId} used ${totalUsage.inputTokens ?? "unknown"} input and ${totalUsage.outputTokens ?? "unknown"} output tokens`);
                    },
                  });
                  return createTextStreamResponse({ stream: toTextStream({ stream: result.stream }) });
                }
                

                Choose an answer to see the explanation.

              5. Question 5 of 7Asha clicks Stop. What does abortSignal: request.signal do?

                Choose an answer to see the explanation.

              6. Question 6 of 7The model answers with <img src="x" onerror="…">. The page shows it with <p>{answer}</p>. What happens?

                Choose an answer to see the explanation.

              7. Question 7 of 7Which of the lesson's model calls works before the page's JavaScript has loaded?

                Choose an answer to see the explanation.

              Key takeaways

              • Call the model from the server. streamText returns at once; a Route Handler sends its answer as plain text with createTextStreamResponse, or for useChat with createUIMessageStreamResponse.
              • A Server Action can call generateText for a short answer a form waits for. Streams from Server Actions (@ai-sdk/rsc) are experimental and can't be cancelled.
              • useChat keeps the messages and sends all of them on every call, so long chats cost more each time.
              • Keep the key in a variable without NEXT_PUBLIC_, read in a module that starts with import "server-only".
              • Before the model: check the session, validate the input, and count calls per user. Cap answers with maxOutputTokens, and pass request.signal so the call stops when the visitor leaves.
              • The answer is untrusted: show it as text, never as HTML, and check access in your own code before a tool does anything.

              Remember the one-liner.
              The browser asks your server; your server checks, then asks the model, and streams back text it doesn't trust.

              The versions in this lesson

              Every example was built with Next.js 16.4.3 and AI SDK 7.0.157 (@ai-sdk/react 4.0.161, @ai-sdk/rsc 3.0.157 and @ai-sdk/openai-compatible 3.0.81), the latest on npm on October 30, 2026. The AI SDK changes its names between major versions: code written for version 5 or 6 may use toUIMessageStreamResponse() and system, which version 7 still accepts but marks deprecated.

              Coming next: Core Web Vitals, the first lesson of the Production stage: measuring how fast the app feels to real visitors, and fixing the usual causes in a Next.js app.

              CompleteFrontend Clear concepts. Working examples.