Overview
Next.js is where most AI products end up, and the App Router's server/client split maps unusually well onto the problem — because the one thing that must never reach the browser is your API key.
This lesson is the request path through an App Router app: which parts render on the server, where the model call belongs, how a route handler returns a stream straight to the client, and how the runtime choice bites you on long generations.
Theory
Server Components are the default, deliberately. They run on the server, never ship to the browser, and can hold secrets. In an AI app that matters more than usual: your API key belongs in code the client never receives.
Client Components are for the parts that need interactivity. "use client" marks a boundary, not a whole page. The input box and the streaming message list need it. The layout around them does not, and pushing the boundary as deep as possible keeps the bundle small.
Route handlers are your AI backend. A route.ts is where the model call lives, where the key is read, and what returns the stream. If you find yourself wanting the key in a NEXT_PUBLIC_ variable, that is the signal you are calling from the wrong side.
Streaming is structural here, not a library trick. A route handler can return a Response whose body is a stream, and the framework forwards it. No polling, no websocket, no special client.
Runtime choice matters for long calls. Edge starts faster and is cheaper for short work but has stricter limits and a smaller API surface. Node handles longer generations and libraries that need real Node built-ins. Pick per route, not per project.
Visual Diagram
Read it left to right. The browser holds a Client Component ("use client") that captures user input and calls
fetch("/api/chat"). That request lands on a Route Handler running on the server — the only place that holds
the model provider's API key. The Route Handler calls the provider, gets back a token stream, and forwards that
stream as the Response body. The browser reads the response body incrementally and updates the UI as bytes
arrive, so the user sees words appear before the model has finished generating the full answer. At no point does
the API key, or any server-only logic, cross into code the browser can inspect.
Examples
- A chat page where the message list and input box are a Client Component using
useChat, but the surrounding layout (nav, sidebar, footer) stays a Server Component and never re-renders on the client. - A Route Handler at
app/api/chat/route.tsthat proxies to a model provider (in this codebase, the Lovable AI Gateway viasrc/lib/ai-gateway.server.ts) and streams the response back. - A loading skeleton shown via
loading.tsxwhile the first token of a slow generation is still in flight, so the page never looks frozen even before streaming kicks in. - A Suspense boundary around a single panel — for example a "related documents" RAG panel that takes longer than the rest of the page — so a slow retrieval step doesn't block an otherwise-instant page load.
Code
// app/api/chat/route.ts — the server-only endpoint that holds the API key.
import { streamText } from "ai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
export const runtime = "nodejs"; // long-lived streaming needs the full Node runtime here
const provider = createOpenAICompatible({
name: "lovable-ai-gateway",
baseURL: "https://ai.gateway.lovable.dev/v1",
headers: { "Lovable-API-Key": process.env.LOVABLE_API_KEY ?? "" },
});
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: provider("gpt-4o-mini"),
system: "You are a concise, helpful assistant.",
messages,
});
// Turns the token stream into a Response the browser can read
// incrementally — no waiting for the full answer before anything renders.
return result.toTextStreamResponse();
}// app/chat/page.tsx — a Client Component; it needs local state and live
// updates as tokens arrive, which a Server Component cannot do on its own.
"use client";
import { useState } from "react";
export default function ChatPage() {
const [input, setInput] = useState("");
const [reply, setReply] = useState("");
async function send() {
setReply("");
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ messages: [{ role: "user", content: input }] }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
// Read the stream chunk by chunk and append to the UI as it arrives —
// this is what makes a model response feel instant instead of frozen.
for (;;) {
const { done, value } = await reader.read();
if (done) break;
setReply((prev) => prev + decoder.decode(value));
}
}
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={send}>Send</button>
<p>{reply}</p>
</div>
);
}Notice the boundary: LOVABLE_API_KEY is read only inside app/api/chat/route.ts, which never ships to the
browser. ChatPage only ever talks to your own /api/chat endpoint over a normal fetch — it has no way to see
the key even if you wanted it to. That separation is the entire reason Route Handlers exist for AI features.
Real Product Example
v0 by Vercel is a clean example of this architecture in production. A user describes a UI in natural
language; that prompt is sent to a server-side endpoint, which calls a model with streamText and streams the
response back token by token. The browser renders the generated code (and a live preview of it) incrementally as
it streams in, rather than showing a spinner until the entire component is done generating — the same
Response-body-as-a-stream pattern shown in the Code section above, just generating React/JSX instead of chat
replies. The product's core "feel" — code appearing progressively while you watch — is a direct consequence of
choosing a framework where server-to-client streaming is a first-class primitive instead of something bolted on
with a polling loop.
Common Mistakes
Making the chat UI a Server Component
Server Components render once on the server and can't hold interactive local state or re-render as new data
arrives on the client. A component using useState, an onClick handler, or the AI SDK's useChat hook must be
a Client Component — trying to keep it a Server Component to "save JavaScript" just makes it non-functional.
Awaiting the full model response before responding to the client
Calling generateText inside a Route Handler and returning Response.json({ text }) only after the entire
generation finishes throws away the whole benefit of streaming — the user waits the full latency with nothing on
screen, then everything appears at once. Use streamText and return the stream unless the feature genuinely
needs the complete text before doing anything else (for example, a background job with no UI to update).
Putting the model API key in a Client Component or a NEXT_PUBLIC_ env var
Any environment variable prefixed NEXT_PUBLIC_ gets bundled directly into the JavaScript sent to every visitor
and is readable in the browser's devtools. Provider API keys must be read only inside server-only code — Route
Handlers, Server Components, Server Actions — using a plain (non-NEXT_PUBLIC_) environment variable.
Assuming Edge runtime will happily run a long streaming generation
Deployment platforms cap function duration differently, and some Edge configurations are tuned for short, latency-sensitive requests, not multi-second model generations. Verify the actual duration limits of your Edge/Node runtime and deployment target before shipping a chat feature that can legitimately run 20–30+ seconds.
Senior Tips
Senior tip
Put loading.tsx (or a manual <Suspense>) one level above the slow thing, not the whole page. If only the
AI panel is slow, wrap just that panel in its own boundary rather than the entire route — otherwise a slow model
call blocks the header, nav, and everything else that could have rendered instantly.
Senior tip
Treat the Route Handler as the trust boundary, not the page. Input validation, rate limiting, and per-user quota checks belong in the Route Handler (or middleware in front of it), never only in the client component — the client component's code is fully visible and editable by anyone with devtools open.
Senior tip
Wire an AbortController into every streaming fetch. A user who navigates away or clicks "Stop" while a
generation is in flight should actually cancel the request — otherwise you keep paying for tokens nobody will
ever see, and keep a connection open for no reason. Cancel the controller in your component's cleanup / stop
handler.
Senior tip
Prefer Route Handlers over Server Actions for streaming AI responses. Server Actions were designed around a
single resolved value returned to the caller, not a byte stream. Route Handlers give you direct control over the
Response object and its headers, which is what streaming SDKs and the raw Fetch API both expect.
Exercises
Exercise
Build the streaming Route Handler and chat page from the Code section, wired to a real provider (the Lovable AI Gateway or any provider you have a key for). Open your browser's Network tab and confirm the response body arrives in multiple chunks over time rather than as a single payload after a long wait.
Exercise
Add an AbortController to the send function so a "Stop" button cancels an in-flight generation cleanly (no
console errors, no further UI updates after cancellation). Note which Senior Tip above this connects to and why
it matters for cost.
Exercise
Split the chat page into a Server Component shell (header, layout) and a "use client" chat panel containing
only the interactive parts. Compare the JavaScript shipped to the browser before and after the split using your
browser's devtools, and write one paragraph explaining the difference you observed.
Mini Project
Streaming echo endpoint. Before wiring up a real model, prove the streaming mechanics work end to end with something you fully control.
- Build
app/api/echo/route.ts: aPOSThandler that takes{ text: string }, splits it into words, and streams them back one at a time with a short artificial delay between each — simulating token-by-token model output without spending a single real token. - Build a client page that sends text to
/api/echoand renders the streamed words as they arrive, exactly like the chat page in the Code section. - Add a
loading.tsxskeleton that shows before the first word arrives. - Add a "Stop" button wired to an
AbortControllerthat cancels the in-flight stream.
This gives you a fully working streaming UI you can swap a real model into later without changing the client code at all — only the Route Handler's internals change.
Resources
- Next.js — Route Handlers: the canonical
reference for
route.tsfiles, including the streaming example this lesson's Code section builds on. - Vercel AI SDK — Next.js App Router Quickstart: a
full walkthrough of wiring
streamText/useChatinto an App Router project, including tool calling. - Next.js — loading.js (Streaming UI): how
loading.tsxand Suspense boundaries stream a route's UI progressively instead of blocking on the slowest part.