Claude API Streaming with Next.js Server Actions
Can Server Actions stream Claude API output?
Not by default, and that trips up a lot of developers moving from a traditional REST streaming setup to the Next.js App Router. A Server Action is an RPC call: the client invokes a function on the server, and that function returns a single serialized value once it's done. There's no built-in mechanism for a Server Action to push partial chunks back to the client the way a fetch response with ReadableStream does.
To get real token-by-token streaming from the Claude API inside a Server Action, you need to pair the action with React's streaming primitives — specifically a streamable value that resolves progressively as part of the React Server Component payload. This is different from the classic Server-Sent Events pattern you'd use in a Route Handler, but it works, and it's the officially supported way to stream from a Server Action in the App Router.
Below are the two patterns that actually work, when to use each, and the gotchas that cause silent failures (frozen UI, no chunks, or actions that just hang until the full response is ready).
Pattern 1: Route Handler + fetch streaming (simplest, most reliable)
If you don't strictly need a Server Action, a Route Handler is the path of least resistance. It gives you a raw ReadableStream response that the client reads with a standard fetch reader — no special React wiring required.
// app/api/chat/route.ts
export const runtime = "edge";
export async function POST(req: Request) {
const { prompt } = await req.json();
const upstream = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUBTOAPI_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4",
max_tokens: 1024,
stream: true,
messages: [{ role: "user", content: prompt }],
}),
});
return new Response(upstream.body, {
headers: { "Content-Type": "text/event-stream" },
});
}
On the client, read the stream directly:
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt: "Summarize this ticket" }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log(decoder.decode(value));
}
This is the recommended approach when streaming is the main job of the endpoint. Routing through SubToAPI here means you get a sub_live_... application key instead of a raw Anthropic key, plus usage metadata per request — useful if multiple features in your app share one Claude subscription. See /docs/streaming for the full event format.
Pattern 2: True streaming from a Server Action
Sometimes you want the action itself — the thing called from a form action prop or a direct startTransition call — to stream. That requires a streamable value abstraction (the ai SDK's createStreamableValue is the common implementation, but the same idea works with any async generator wrapped in a promise-like object React can read incrementally).
// app/actions.ts
"use server";
import { createStreamableValue } from "ai/rsc";
export async function streamClaudeResponse(prompt: string) {
const stream = createStreamableValue("");
(async () => {
const res = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUBTOAPI_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4",
max_tokens: 1024,
stream: true,
messages: [{ role: "user", content: prompt }],
}),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let text = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
text += decoder.decode(value);
stream.update(text);
}
stream.done();
})();
return { output: stream.value };
}
On the client, consume it with readStreamableValue:
"use client";
import { useState } from "react";
import { readStreamableValue } from "ai/rsc";
import { streamClaudeResponse } from "./actions";
export function Chat() {
const [text, setText] = useState("");
async function onSubmit(formData) {
const { output } = await streamClaudeResponse(formData.get("prompt"));
for await (const delta of readStreamableValue(output)) {
setText(delta ?? "");
}
}
return (
<form action={onSubmit}>
<input name="prompt" />
<p>{text}</p>
</form>
);
}
This keeps everything inside the Server Action model — no separate API route — at the cost of pulling in the streamable-value abstraction and running on the nodejs or edge runtime depending on your deployment target.
Gotchas that actually bite you
- Edge vs Node runtime:
fetchstreaming works on both, but some serverless platforms buffer the response unless you explicitly set the runtime toedgeor configuremaxDuration. Check your platform's docs before assuming a hang is a code bug. - Server Actions aren't idempotent-safe for retries: if a client retries a failed action mid-stream, you can duplicate Claude API calls (and spend). Debounce on the client or dedupe with a request ID.
- Raw Anthropic keys vs application keys: if you call the Claude API directly, you're exposing usage and rate limits tied to your personal account across every server instance. Routing through SubToAPI gives each app or environment its own
sub_live_...key so you can see per-key usage instead of one undifferentiated number — see /docs for setup and /docs/quickstart for a first call. - Tool use inside streams: if your action also needs tool calls, the streaming payload includes tool-use blocks interleaved with text — don't assume every chunk is plain text. See /docs/tools for the block structure.
- Timeouts: long Claude responses can exceed default serverless function timeouts. Stream early and often rather than buffering the whole response before returning.
Wrapping up
Use a Route Handler when streaming is the primary job of an endpoint — it's simpler and easier to debug with plain fetch. Use the streamable-value pattern when you specifically need the ergonomics of a Server Action (form integration, progressive enhancement, no separate API route). Either way, pointing the underlying request at an API that gives you per-key usage and a stable streaming contract — rather than juggling console-only Anthropic access — saves debugging time later. /pricing has the plan breakdown if you want to try it with a free trial.
questions
Do Next.js Server Actions support Server-Sent Events natively? No. Server Actions return a single resolved value by default. To stream partial output you need a streamable value abstraction (like createStreamableValue) or switch to a Route Handler that returns a ReadableStream directly.
Should I stream Claude responses through a Route Handler or a Server Action? Use a Route Handler if streaming is the core feature — it's simpler to reason about and debug. Use a Server Action if you need it tied to a form submission or React transition and are fine with the extra streamable-value wiring.
Why does my stream hang until the full Claude response is ready? Usually a runtime or buffering issue: the serverless platform is collecting the whole response before sending it, often because the function isn't running on an edge runtime or is missing a streaming-compatible response type. Set runtime = "edge" and return the raw stream body without awaiting it fully first.