In a Next.js app that streams AI responses, what belongs on the server and what belongs on the client?
Advertisement
🧩 Scenario
Concept Walkthrough
Start with the trust boundary
The simple rule: anything the user can open in DevTools is public and editable. The browser bundle, request bodies and props passed to Client Components are all visible. So the question is not "where is it convenient?" but "what breaks if a user changes it?"
What the server owns
- Secrets: provider API keys stay in environment variables without the
NEXT_PUBLIC_prefix. - Model choice and instructions: the system prompt is part of your product logic. If the client can send it, users can rewrite it. The same goes for the model id, which controls cost.
- Tools: a tool's
executefunction often reads a database or calls an internal API. That needs server permissions. - Guards: auth, input size limits, rate limits and validation of the message history.
- Side effects: saving the conversation, logging token usage, analytics.
What the client owns
- The input box and its state
- The message list and rendering each message's
parts(text, tool calls and so on) - Status UI: a typing indicator while
statusissubmitted, a Stop button while it isstreaming - Error UI with a Retry action that calls
regenerate() - Scroll, focus and accessibility
How it maps to the App Router
A Route Handler at app/api/chat/route.ts receives the messages, runs streamText and returns a streamed Response. A Client Component with useChat from @ai-sdk/react posts to /api/chat by default and renders the stream. A Server Component page can load saved messages and pass them in, so only the chat is client code, not the whole page. This is the same split you already use for a form: the form is a Client Component, the mutation and validation happen on the server.
Key Code Explained
// app/api/chat/route.ts (server)
import {
streamText,
convertToModelMessages,
toUIMessageStream,
createUIMessageStreamResponse,
type UIMessage,
} from 'ai';
import { getSession } from '@/lib/auth'; // your own auth helper
export const maxDuration = 30;
const MODELS = {
fast: 'anthropic/claude-sonnet-5.5', // example ids, keep the list on the server
} as const;
export async function POST(req: Request) {
const session = await getSession(req);
if (!session) return new Response('Unauthorized', { status: 401 });
const { messages }: { messages: UIMessage[] } = await req.json();
if (messages.length > 50) {
return new Response('Conversation too long', { status: 413 });
}
const result = streamText({
model: MODELS.fast, // chosen here, never taken from the request
instructions: 'You are the IOCombats study assistant. Be concise.',
messages: await convertToModelMessages(messages),
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}
// app/chat/chat.tsx (client)
'use client';
import { useChat } from '@ai-sdk/react';
import type { UIMessage } from 'ai';
import { useState } from 'react';
export function Chat({ initialMessages }: { initialMessages: UIMessage[] }) {
const [input, setInput] = useState('');
const { messages, sendMessage, status, stop, error, regenerate } = useChat({
messages: initialMessages,
});
return (
<div>
{messages.map((m) => (
<div key={m.id}>
{m.parts.map((part, i) =>
part.type === 'text' ? <p key={i}>{part.text}</p> : null,
)}
</div>
))}
{status === 'submitted' && <p>Thinking...</p>}
{status === 'streaming' && <button onClick={() => stop()}>Stop</button>}
{error && <button onClick={() => regenerate()}>Something went wrong. Retry</button>}
<form
onSubmit={(e) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage({ text: input });
setInput('');
}}
>
<input value={input} onChange={(e) => setInput(e.target.value)} />
</form>
</div>
);
}
The route never reads a model id or instructions from the body. The client never sees the key or the instructions. Error text in the UI is generic, so internal details from the server are not shown.
Tradeoffs
| Decision | Benefit | Cost |
|---|---|---|
| Client sends full message history | Simple, stateless server | Client can edit history, larger requests |
| Client sends only the latest message | Server controls history, smaller requests | Server must load and store messages |
| Server Component loads initial chat | Fast first paint, less client fetching | Needs a page per chat id and a database read |
| Tools executed on the server | Real permissions, secrets stay safe | Each tool step adds a server round trip |
What Interviewers Actually Check
- Whether you reason from "what can the user edit?" rather than convenience
- Whether the system prompt and model id stay on the server
- Whether you put auth, limits and validation in the route before the model call
- Whether you keep only the interactive part as a Client Component
- Whether the client handles all four
useChatstatuses: submitted, streaming, ready and error
Follow-Up Questions
- How would you send only the latest message and load history on the server?
- Where would you check that a user is allowed to read a chat id?
- Which parts of this design change if a tool needs the user's confirmation in the browser?
- How would you save the assistant message after the stream finishes?
- What should the client show if the route returns 429?
Common Candidate Mistakes
- Putting the provider call in a Client Component and the key in a
NEXT_PUBLIC_variable - Accepting
systemormodelfields from the request body - Making the whole layout a Client Component because one child needs
useChat - Showing raw server error messages in the chat UI
- No limit on message count or size, so one request can be very expensive
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you draw the trust boundary for an AI chat?
- Can you write the Route Handler and Client Component from memory?
- Can you explain why instructions are server-only?
- Can you use a Server Component to provide initial messages?
- Can you map each
useChatstatus to a UI state?
Summary
Decide by trust, not convenience. The server holds the API key, the model choice, the instructions, tool execution, auth, limits and persistence, usually in a Route Handler that streams with streamText. The client holds the input, the message list, rendering of message parts and status, stop and retry UI, usually in one Client Component with useChat. A Server Component page can load saved messages and pass them in, so only the chat itself ships as client JavaScript.
Can the client choose the model or send the system prompt?
It can send a hint, such as "fast" or "smart", but the server should map that hint to a model from an allow-list. Never accept a raw system prompt or model id from the browser, because anyone can edit the request in DevTools.
Where do Server Components fit if the chat itself is a Client Component?
A Server Component page can load the saved conversation from your database and pass it as initial messages to the Client Component. That gives a fast first render without an extra client fetch.
Should I use a Route Handler or a Server Action for the chat endpoint?
useChat posts to an HTTP endpoint (/api/chat by default), so a Route Handler is the natural fit. Route Handlers also make it easy to set headers, return status codes like 429 and stream a Response.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement