← Blog

Claude API Streaming with Next.js Server Actions

2026-10-31 · 5 min read · SubToAPI Team

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

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.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →