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.
- 01Stream 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.
- 02Keep 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.
- 03Treat 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.
app/api/advice/route.tsimport { 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 }) });}Each step was taken in real Chrome against next start. Pick one to see the page after it.
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
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 fromapp/_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.maxOutputTokensandabortSignal, 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.
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.
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
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:
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:
| You write | What the response holds | Who 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:
"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:
{
"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:
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;}POST /describe200
- Sent with the request
cookiesession=eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MSIsImlhdCI6MTc5MzMxODQwMCwiZXhwIjo0MTAyNDQ0ODAwfQ.sY9GVxj1-v3mOJvS4ev9s8MSmkxEorTL7e01C0TNkVQ
- Typed into the form (sent with its hidden fields)
teaMint tea
- Response headers
cache-controlno-cache, no-store, max-age=0, must-revalidatecontent-typetext/html; charset=utf-8varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-powered-byNext.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="{"id":"60344250cce960b590c520fb6d4cdfe2d699c20365","bound":"$@1"}">
<input type="hidden" name="$ACTION_1:1" value="["Mint tea is fresh and cool, with a sweet finish after every sip."]">
<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:
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 };}Each step was taken in real Chrome against next start. Pick one to see the page after it.
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
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.
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:
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> </> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
- 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
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
POST/api/chat200text/event-stream
In app/chat/chat.tsx:
- Line 8 calls
useChat(). With no options it posts to/api/chat. It gives backmessages,sendMessage, andstatus, 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, afterYou:orAdvisor:. - 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:
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:
{
"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.
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:
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:
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▲ 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 framesLine 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 | Is the key safe there? | Why |
|---|---|---|
.env (or the host's settings) as MODEL_API_KEY | Yes, 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_KEY | No. | Next.js writes its value into the JavaScript that goes to the browser. |
| A Client Component, or anything it imports | No. | Its code goes to the browser, where anyone can read it. |
| Anything a component renders, returns, or passes as a prop | No. | 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:
app/api/advice/route.tsimport { 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 }) });}POST /api/advice401
- Sent with the request
content-typeapplication/json
The body sent with the request
{"question":"Which tea helps me sleep?"}- Response headers
content-typeapplication/jsonvaryrsc, 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
currentUserIdfromapp/_lib/guard.ts, which reads the signed session cookie from Authentication (app/_lib/session.tsis 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
400for anything else. - How often. Lines 15 to 17 allow three model calls a minute for each user:
allowCallinapp/_lib/guard.tsusesallowTryfrom 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.
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:
app/api/advice/route.tsimport { 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 }) });}Each step was taken in real Chrome against next start. Pick one to see the page after it.
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:
Asha clicks Stopafter step 1Tea advisor
Which tea helps me sleep? Ask Stop
React is running: the page responds to clicks.
The page isn't complete yet.
ask() makes an AbortController and sends POST /api/advice with its signal.
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.
app/advisor.tsxNext.jsapp/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> </> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
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
POST/api/advice200text/plain
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.
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:andmailto:, and neverjavascript:; - 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:
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?
- 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.signaltostreamText.
Sort each of the tea advisor's choices.
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.signaltostreamText. 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.
These are the advisor's files. Which one reads MODEL_API_KEY?
.env
app/_lib/model.ts
app/advisor.tsx
app/api/advice/route.ts
app/layout.tsx
app/page.tsximport "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");
app/_lib/model.ts. Line 8 reads process.env.MODEL_API_KEY, and line 1 makes the module server-only. No other file of the advisor reads the key, and the recording found it in none of the page's JavaScript.
What max_tokens does the model provider receive from this route?
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 }) });
}
200. Line 23 sets maxOutputTokens: 200, and the AI SDK sends it as "max_tokens":200, as all three calls the stand-in recorded show.
These six requests reach the advisor's route, one after the other, within a minute. How many calls reach the model?
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.Three. Requests 1, 2 and 6 are refused with 401, 400 and 429 before streamText runs. Requests 3, 4 and 5 reach the model, and the stand-in recorded exactly three calls.
This advisor ran code from the model's answer. Which line has to change, and to what?
"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 }} />
</>
);
}
"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>
</>
);
}
Line 32. <p>{answer}</p> shows the answer as text, so an <img> tag in it is only characters. The recording of this fixed version showed the tag as text and ran nothing.
Someone tries to skip the Route Handler and call the model in the Client Component. The build stops. Which import stops it?
"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>
</>
);
}
import "server-only", line 1 of app/_lib/model.ts. The recorded build stopped with 'server-only' cannot be imported from a Client Component module, so the key's module never reached the browser.
In what order should the advisor's route do these four steps?
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.C, B, D, A: who is asking, then whether the question is valid, then the rate limit, then the model. That's the order of lines 9, 12, 15 and 19 in the advisor's route. An empty question then doesn't use up one of Asha's three questions.
Check your understanding
For each question, ask where the code runs, what reaches the model, and what the page does with the answer.
Question 1 of 7What does this Route Handler send back while the model is still writing?
Read the code, then predictTypeScriptimport { 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.
Question 2 of 7Where should the model provider's key be read?
Choose an answer to see the explanation.
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.
Question 4 of 7In this route, why does
allowCallcome beforestreamText?Read the code, then predictTypeScriptimport { 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.
Question 5 of 7Asha clicks Stop. What does
abortSignal: request.signaldo?Choose an answer to see the explanation.
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.
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.
streamTextreturns at once; a Route Handler sends its answer as plain text withcreateTextStreamResponse, or foruseChatwithcreateUIMessageStreamResponse. - A Server Action can call
generateTextfor a short answer a form waits for. Streams from Server Actions (@ai-sdk/rsc) are experimental and can't be cancelled. useChatkeeps 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 withimport "server-only". - Before the model: check the session, validate the input, and count calls per user. Cap answers with
maxOutputTokens, and passrequest.signalso 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.
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.