Tutorial: Stream Two Rewrites Side by Side in Next.js
Build a Next.js tool that streams two rewrites of a text in parallel, each with its own Stop and Retry, partial text kept on failure, and cancellation on unmount.
On this page· What you will build
Why this matters
Streaming to a React UI builds a chat where one message streams at a time. Real interfaces often stream several things at once: two drafts to compare, a summary next to a translation, an answer next to its sources. Each stream needs its own state, its own Stop button, and its own failure handling, and none may disturb the others. This tutorial builds a small tool that rewrites a text in two tones side by side, and in doing so turns the article's streaming code into a reusable hook you can drop into any component.
What you will build#
- A Route Handler that streams a rewrite of a text in a given tone, as Server-Sent Events.
- A
useRewritehook: one stream with its own text, status,AbortController, Stop, and Retry, cancelled automatically when the component unmounts. - A page with two panes, each using its own hook instance: one button starts both, and each pane can be stopped, retried, or fail on its own.
Hover or tap a component to see what it does.
Show text descriptionHide text description
Independence comes from state, not from the server: each pane owns its stream.
- User
- Pane A: Its own text, status, and AbortController: Stop and Retry act on this pane only.
- Pane B: A second instance of the same hook, streaming at the same time.
- Route Handler: Validates the text and tone, streams text events, and cancels the model call when the request is aborted.
- Claude
You need Node.js 22 or later and an Anthropic API key. Doing the Next.js prompt library tutorial first helps; this app uses the same project setup.
1. Create the app#
npx create-next-app@latest tone-rewriter --yes
cd tone-rewriter
npm install @anthropic-ai/sdk zodPut ANTHROPIC_API_KEY=sk-ant-... in .env.local. The key is only ever read by the Route Handler, on the server.
2. Shared pieces#
The SSE helpers are the ones from the article:
export function sseEvent(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
export const SSE_HEADERS = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
} as const;export interface SSEMessage {
event: string;
data: string;
}
export async function* readSSE(response: Response): AsyncGenerator<SSEMessage> {
if (!response.ok || !response.body) throw new Error(`Stream failed with HTTP ${response.status}`);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) return;
buffer += value.replace(/\r\n/g, "\n");
let boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
const raw = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
boundary = buffer.indexOf("\n\n");
let event = "message";
const data: string[] = [];
for (const line of raw.split("\n")) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
}
if (data.length > 0) yield { event, data: data.join("\n") };
}
}
}The list of tones is shared by the server (validation) and the client (the select options), so the two can never disagree:
// Shared by the route (validation) and the UI (the select options).
export const TONES = ["friendly", "formal", "concise", "playful"] as const;
export type Tone = (typeof TONES)[number];3. The streaming route#
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { SSE_HEADERS, sseEvent } from "@/lib/sse";
import { TONES } from "@/lib/tones";
const anthropic = new Anthropic();
const Body = z.object({
text: z.string().trim().min(1).max(5_000),
tone: z.enum(TONES),
});
export async function POST(request: Request) {
// In a real app: check the session and the user's rate limit here, before any model call.
const parsed = Body.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "Send some text and a supported tone" }, { status: 400 });
const { text, tone } = parsed.data;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(sseEvent(event, data)));
try {
const modelStream = anthropic.messages.stream(
{
model: "claude-opus-5-5",
max_tokens: 2048,
output_config: { effort: "low" },
system: `You rewrite text. Keep the meaning and every fact; change only the tone. Tone: ${tone}. Reply with the rewritten text only.`,
messages: [{ role: "user", content: `<text>\n${text}\n</text>` }],
},
{ signal: request.signal }, // Stop in the browser cancels the model call too
);
for await (const event of modelStream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") send("text", { text: event.delta.text });
}
const final = await modelStream.finalMessage();
send("done", { stop_reason: final.stop_reason });
} catch (error) {
if (request.signal.aborted) return;
console.error("rewrite stream failed", error);
send("error", { message: error instanceof Anthropic.APIError ? "The model is unavailable right now." : "The rewrite was interrupted." });
} finally {
try {
controller.close();
} catch {
// already closed because the client disconnected
}
}
},
});
return new Response(stream, { headers: SSE_HEADERS });
}The tone is chosen from a fixed list, so it can go in the system prompt; the user's text goes in the user turn, inside tags. Two panes means two independent requests to this route, each with its own request.signal.
4. One stream, as a hook#
This is the reusable part. Everything a single stream needs lives inside the hook, so a component can call it once per stream.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { readSSE } from "@/lib/read-sse";
import type { Tone } from "@/lib/tones";
export type Status = "idle" | "streaming" | "done" | "stopped" | "error";
/**
* One independent stream: its own text, status, and AbortController.
* Call the hook once per pane and the panes cannot interfere with each other.
*/
export function useRewrite() {
const [text, setText] = useState("");
const [status, setStatus] = useState<Status>("idle");
const controllerRef = useRef<AbortController | null>(null);
const lastRequest = useRef<{ input: string; tone: Tone } | null>(null);
const start = useCallback(async (input: string, tone: Tone) => {
controllerRef.current?.abort(); // a new run replaces any run still in flight
const controller = new AbortController();
controllerRef.current = controller;
lastRequest.current = { input, tone };
setText("");
setStatus("streaming");
try {
const response = await fetch("/api/rewrite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: input, tone }),
signal: controller.signal,
});
let finished = false;
for await (const event of readSSE(response)) {
if (event.event === "text") setText((t) => t + (JSON.parse(event.data) as { text: string }).text);
else if (event.event === "done") finished = true;
else if (event.event === "error") throw new Error((JSON.parse(event.data) as { message: string }).message);
}
// A stream that ends without "done" was cut off: treat it as an error, keep the partial text.
setStatus(finished ? "done" : "error");
} catch {
if (controllerRef.current === controller) setStatus(controller.signal.aborted ? "stopped" : "error");
}
}, []);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const retry = useCallback(() => {
if (lastRequest.current) void start(lastRequest.current.input, lastRequest.current.tone);
}, [start]);
// Leaving the page cancels the request, so nobody pays for text no one will see.
useEffect(() => () => controllerRef.current?.abort(), []);
return { text, status, start, stop, retry };
}Four decisions make it robust:
- A new run aborts the previous one. Clicking "Rewrite" twice can never leave two streams writing into the same pane.
controllerRef.current === controllerin the catch. When a new run has already replaced this one, the old run's failure must not overwrite the new run's status.- No
doneevent means an error. A connection that drops mid-answer ends the response cleanly from the reader's point of view; only the missingdonetells you the text is incomplete. The partial text stays visible. - The
useEffectcleanup aborts on unmount, which cancels the model call on the server throughrequest.signal.
5. Two panes#
"use client";
import { useState } from "react";
import { TONES, type Tone } from "@/lib/tones";
import { type Status, useRewrite } from "./use-rewrite";
const LABEL: Record<Status, string> = { idle: "", streaming: "writing…", done: "done", stopped: "stopped", error: "interrupted" };
function Pane({ tone, onTone, run }: { tone: Tone; onTone: (t: Tone) => void; run: ReturnType<typeof useRewrite> }) {
return (
<section className="flex flex-1 flex-col gap-2 rounded-lg border p-3" aria-label={`${tone} rewrite`}>
<div className="flex items-center justify-between gap-2">
<select value={tone} onChange={(e) => onTone(e.target.value as Tone)} disabled={run.status === "streaming"} className="rounded border px-2 py-1">
{TONES.map((t) => (
<option key={t}>{t}</option>
))}
</select>
<span className="text-sm text-gray-500" data-status={run.status}>
{LABEL[run.status]}
</span>
</div>
<output aria-live="polite" aria-busy={run.status === "streaming"} className="min-h-40 whitespace-pre-wrap">
{run.text}
</output>
<div className="flex gap-2">
{run.status === "streaming" ? (
<button type="button" onClick={run.stop} className="rounded border px-2 py-1">
Stop
</button>
) : null}
{run.status === "error" || run.status === "stopped" ? (
<button type="button" onClick={run.retry} className="rounded border px-2 py-1">
Retry
</button>
) : null}
</div>
</section>
);
}
export function Rewriter() {
const [input, setInput] = useState("");
const [tones, setTones] = useState<[Tone, Tone]>(["friendly", "formal"]);
const left = useRewrite();
const right = useRewrite();
const busy = left.status === "streaming" || right.status === "streaming";
function rewriteBoth(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = input.trim();
if (!text) return;
// Two independent requests, streaming at the same time.
void left.start(text, tones[0]);
void right.start(text, tones[1]);
}
return (
<div className="flex flex-col gap-4">
<form onSubmit={rewriteBoth} className="flex flex-col gap-2">
<label htmlFor="input" className="font-medium">
Text to rewrite
</label>
<textarea id="input" value={input} onChange={(e) => setInput(e.target.value)} rows={5} maxLength={5_000} className="rounded border p-2" />
<div className="flex gap-2">
<button type="submit" disabled={!input.trim() || busy} className="rounded bg-black px-3 py-1.5 text-white disabled:opacity-50">
Rewrite both
</button>
{busy ? (
<button
type="button"
onClick={() => {
left.stop();
right.stop();
}}
className="rounded border px-3 py-1.5"
>
Stop both
</button>
) : null}
</div>
</form>
<div className="flex flex-col gap-4 md:flex-row">
<Pane tone={tones[0]} onTone={(t) => setTones([t, tones[1]])} run={left} />
<Pane tone={tones[1]} onTone={(t) => setTones([tones[0], t])} run={right} />
</div>
</div>
);
}The page stays a Server Component and renders the client island:
import { Rewriter } from "./rewriter";
// A Server Component page: static shell, one interactive island.
export default function Home() {
return (
<main className="mx-auto w-full max-w-4xl p-6">
<h1 className="mb-4 text-2xl font-bold">Tone rewriter</h1>
<Rewriter />
</main>
);
}npm run build shows ○ / (a static page) and ƒ /api/rewrite (on demand), which is exactly the split you want.
6. Try every path#
Run npm run dev, paste a paragraph, and click Rewrite both:
- Parallel. Both panes show
writing…at the same time and fill in independently. - Stop one. Press Stop in the left pane mid-stream. It shows
stoppedand keeps its partial text; the right pane runs todone. - Retry one. Press Retry in the left pane. Only that pane restarts.
- Failure. Stop your network connection (or the dev server) mid-stream: both panes show
interruptedwith the text they had, and a Retry button. - Leave. Navigate away mid-stream. The server log shows no error, because an aborted request is not a failure, and the model call stops.
These behaviors were checked with a headless browser against a mock of the API that streams, and drops connections, on demand.
Common mistakes#
- One
AbortControllerfor several streams. Stopping one stream stops them all. Keep a controller per stream, which the hook does by construction. - Treating the end of the response as success. A dropped connection also ends the stream. Only an explicit
doneevent means complete. - Clearing partial text on error. Users would rather see half an answer and a Retry button than an empty box.
- Letting an old run update state. If a newer run replaced it, ignore the old run's late results and errors.
- Forgetting cleanup on unmount. The request keeps running and keeps generating, billed, text nobody will see.
Try it
Extend the rewriter
- Add a third pane. How much code did it take, and what does that say about putting the stream in a hook?
- Add a "Use this" button under each finished rewrite that copies it to the clipboard.
- Show the output token count of each finished rewrite, sent in the
doneevent fromfinal.usage. - Rate-limit the route per IP with the limiter from Foundations Tutorial 5, counting two parallel panes as two requests.
Check your understanding
Key takeaways
- Put one stream's state, controller, Stop, and Retry in a hook, and call it once per concurrent stream.
- Only an explicit completion event means success; keep partial text and offer Retry on anything else.
- Abort on unmount and pass
request.signalto the model call, so leaving the page stops generation. - Ignore late results from a run that a newer run has replaced.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Streaming LLM Responses to a React UI with Next.js and SSEStream Claude responses token by token from a Next.js Route Handler to a React client over Server-Sent Events, with stop, retry, error handling, and accessible updates.
- Tutorial: Build a Prompt Library with Next.js Server ComponentsBuild a Next.js prompt library with Server Components, validated Server Actions, cached reads with tags, and a streamed preview with a Stop button.