A robust streaming agent interface in Next.js uses two separate streams: React Suspense streams page subtrees as server data becomes ready, while a Route Handler streams typed agent events to an interactive client. Authenticate and validate before either response begins, propagate cancellation to every model and tool, respect Web Streams backpressure, and persist side effects independently from the connection.
Choose the stream you actually need
Next.js App Router supports two related but distinct mechanisms:
| Need | Mechanism | Payload |
|---|---|---|
| Reveal server-rendered panels progressively | loading.tsx or <Suspense> | React component payload and HTML |
| Show tokens, tool phases, and run progress after user input | Route Handler + ReadableStream | application protocol such as NDJSON |
The official Next.js streaming guide explains that React’s server renderer emits chunks around Suspense boundaries. On initial load, the component payload is embedded in the HTML stream; on client navigation, the client receives the component payload rather than another HTML document.
Do not call an internal Route Handler from a Server Component just to stream data. The Next.js production guidance recommends fetching directly in Server Components to avoid the extra server hop. Use a Route Handler when a Client Component needs a browser-facing run protocol.
Stream the initial page with meaningful boundaries
Put a fast, stable frame outside Suspense and independently paced data inside it:
// app/agents/[runId]/page.tsx
import { Suspense } from "react";
export default async function Page({
params,
}: {
params: Promise<{ runId: string }>;
}) {
const { runId } = await params;
await assertRunVisibleToCurrentUser(runId);
return (
<main>
<RunHeader runId={runId} />
<Suspense fallback={<TimelineSkeleton />}>
<PersistedTimeline runId={runId} />
</Suspense>
<AgentComposer runId={runId} />
</main>
);
}
Authorization and existence checks happen before a fallback starts the response. The loading.js reference warns that headers and status cannot change after streaming begins; if an accurate 404 matters, determine it before the first suspension. Keep that preflight small so it does not defeat streaming.
Use nested boundaries for independent panels, but avoid a boundary around every line. Fallbacks should match final dimensions to limit layout shift and announce a useful state.
Define an event protocol before writing the stream
Raw token strings cannot describe tool calls, citations, errors, or recovery. Use a discriminated event envelope:
type AgentEvent =
| { seq: number; type: "run_started"; runId: string }
| { seq: number; type: "text_delta"; text: string }
| { seq: number; type: "tool_started"; callId: string; name: string }
| { seq: number; type: "tool_finished"; callId: string; outcome: "ok" | "error" }
| { seq: number; type: "citation"; sourceId: string; url: string }
| { seq: number; type: "run_finished"; outcome: "completed" | "cancelled" }
| { seq: number; type: "run_error"; code: string; retryable: boolean };
Every event carries a monotonically increasing sequence within one run. Persist important transitions before publishing them. The protocol should tolerate duplicate delivery and reconnect by ignoring an event whose sequence was already applied.
Keep private tool arguments, chain-of-thought, credentials, and raw retrieved documents out of client events. A safe tool_started event exposes a reviewed display name and opaque call ID, not the complete request.
Convert an async iterator into a backpressured response
The WHATWG Streams Standard defines pull-based readable streams and cancellation. Convert one iterator item per pull so the producer does not enqueue an unbounded agent transcript:
// app/api/agent-runs/[runId]/events/route.ts
const encoder = new TextEncoder();
function iteratorToNdjson(
iterator: AsyncIterator<AgentEvent>,
): ReadableStream<Uint8Array> {
return new ReadableStream({
async pull(controller) {
try {
const next = await iterator.next();
if (next.done) {
controller.close();
return;
}
controller.enqueue(encoder.encode(`${JSON.stringify(next.value)}\n`));
} catch (error) {
controller.error(error);
}
},
async cancel() {
await iterator.return?.();
},
});
}
export async function POST(
request: Request,
context: { params: Promise<{ runId: string }> },
): Promise<Response> {
const actor = await requireSession();
const { runId } = await context.params;
const input = await parseRunInput(request);
await authorizeRun(actor, runId);
const events = runAgent({
actorId: actor.id,
runId,
input,
signal: request.signal,
});
return new Response(iteratorToNdjson(events[Symbol.asyncIterator]()), {
headers: {
"Content-Type": "application/x-ndjson; charset=utf-8",
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
});
}
This is a protocol pattern, not a complete authentication implementation. The current Route Handler reference confirms that handlers use the Web Request and Response APIs. Pin the Next.js version and test the exact adapter where it will run.
Use server-sent events when its browser semantics fit: one-way event delivery, automatic reconnection, and a GET endpoint. Use fetch plus NDJSON when you need a POST body, application-specific framing, or direct control over reconnect. Whichever protocol you choose, document content type, frame boundaries, terminal events, error codes, and replay behavior.
Propagate cancellation beyond the socket
The browser’s request signal is only the start. Pass it to retrieval, model, and tool calls that support cancellation; stop scheduling new branches; and mark the durable workflow cancelled only when policy allows. The DOM AbortController specification defines the abort signal, but an external provider may already have accepted work when the local request ends.
Do not equate disconnect with rollback. A committed ticket or payment remains committed. Use idempotency keys and explicit workflow states as described in durable AI agents.
Give the user a visible Stop control that aborts the fetch and sends an authenticated cancellation command when the workflow is durable. Make cancellation idempotent and return the final known state.
Preserve failure meaning after status 200
Once the stream starts, an HTTP error status is no longer available. Emit a typed terminal run_error event for expected failures, then close. Reserve abrupt stream errors for transport or protocol failure. The client must distinguish:
- completed run;
- policy refusal;
- user cancellation;
- retryable provider failure;
- non-retryable tool failure;
- connection lost before terminal state.
On reconnect, query durable run state before launching work again. Never repeat side effects merely because the last frame was lost.
Build an accessible client, not a token firehose
Render accumulated text normally and announce coarse milestones through an aria-live="polite" status region. Do not send every token to a screen reader. Announce “Searching documentation,” “Running tests,” and the terminal outcome. Preserve keyboard focus on the composer or Stop button.
Batch visual text updates on the client when token frequency causes excessive rendering. Batching changes paint cadence, not protocol order; retain the sequence number. Keep tool details expandable so progress remains understandable without overwhelming the main answer.
Test proxies, buffering, and overload
Reverse proxies, CDNs, serverless adapters, and compression can buffer chunks. Next.js’s streaming guide explicitly calls out these deployment layers. Verify production behavior with browser network timing and a no-buffer command-line client, not only next dev.
Test:
- first useful event and final event latency;
- split UTF-8 and split NDJSON frames;
- a slow reader to confirm bounded memory;
- disconnect during model generation and during a tool write;
- provider timeout after the response began;
- reconnect from the last persisted sequence;
- proxy buffering and idle timeouts;
- concurrent runs at the admission limit;
- authorization changes during a long run;
- screen-reader announcement frequency.
Streaming does not create capacity. Apply admission control, deadlines, and queue bounds from the backpressure guide, and budget first useful output separately from completion using the agent latency guide.