Search IOCombats

Search challenges, guides, questions and articles

In a Next.js app that streams AI responses, what belongs on the server and what belongs on the client?

Intermediate10 min interview
Skills tested:
Drawing a trust boundary between browser and server for an AI featureWriting a Route Handler that streams a UI message stream from streamTextWriting a Client Component that renders message parts with useChatKeeping model choice and instructions on the server with an allow-listUsing a Server Component to load initial messages

Advertisement

🧩 Scenario

You are building an AI assistant for a Next.js App Router project. A teammate has a prototype where a Client Component calls the provider directly and the system prompt lives in a React state variable. The interviewer asks you to redesign it and explain where each piece of logic should run.

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 execute function 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 status is submitted, a Stop button while it is streaming
  • 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

DecisionBenefitCost
Client sends full message historySimple, stateless serverClient can edit history, larger requests
Client sends only the latest messageServer controls history, smaller requestsServer must load and store messages
Server Component loads initial chatFast first paint, less client fetchingNeeds a page per chat id and a database read
Tools executed on the serverReal permissions, secrets stay safeEach 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 useChat statuses: submitted, streaming, ready and error

Follow-Up Questions

  1. How would you send only the latest message and load history on the server?
  2. Where would you check that a user is allowed to read a chat id?
  3. Which parts of this design change if a tool needs the user's confirmation in the browser?
  4. How would you save the assistant message after the stream finishes?
  5. 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 system or model fields 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 useChat status 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.

Frequently Asked Questions

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.

Namaste Dev
Sponsored

Cohort-style courses to go from tutorials to job-ready.

Explore Courses

Advertisement