DEV Community

Mudassir Khan
Mudassir Khan

Posted on

React Server Components and AI Streaming in Next.js: Patterns That Actually Work

A user hits submit. Your model thinks for eight seconds. The screen shows nothing, then everything appears at once.

That is almost never a model problem. It is a boundary problem. Where you put your Suspense boundaries decides whether the page feels instant or broken, and most of the RSC advice floating around treats this as an architecture question when it is really a placement question. Here is what I actually reach for when wiring LLM output into a Next.js UI, and the mistakes that cost me the most time.


The RSC and streaming mental model you need

Most of us carry the SSR model in our heads: the server does its work, produces one HTML blob, and ships it when the whole thing is ready. React Server Components do not work that way. RSC sends a serialized React component tree over the React Flight protocol via streaming HTTP, not raw HTML.

That distinction sounds academic until you notice what it buys you. Because the payload is a stream of tree updates instead of one document, the server can flush the parts that already resolved and keep the connection open for the parts that have not. Your nav, your heading, your empty state, and your input box can be on screen while the model is still generating token one.

This is why the App Router pairs so naturally with streaming LLM output, and why it is a common first choice for AI heavy apps. You are not bolting streaming onto a render model that fights it.

Here is the shape everyone writes first:

// app/answer/page.tsx
export default async function AnswerPage() {
  const answer = await getAnswer();     // 8s
  return <article>{answer}</article>;
}
Enter fullscreen mode Exit fullscreen mode

Correct, and terrible. That await sits above everything, so the whole route waits on the slowest thing in it. Nothing about RSC saves you here. You have to tell React which part is allowed to arrive late.


Suspense boundary placement for LLM streaming responses

This is the section that matters. Everything else is detail.

With multiple Suspense boundaries, the total wait equals the slowest fetch, not the sum of all fetches. React sends the shell immediately, then streams each boundary's content in as it resolves. On the model side, streaming in Next.js delivers tokens to the browser as the model produces them, so the reader sees output within the first network round trip instead of after the full generation.

The rule I follow: one boundary around the slow model call. Not one around the whole page, not one around every element.

Push the boundary too high and you have rebuilt the blank screen you were trying to fix, because everything inside it (including the static parts) waits on the model. Push it too low and you get a wall of spinners that pop in at different times, each one resizing its container, and the reader's eye chases the layout around the viewport.

The fallback deserves real attention too. Whatever you render while waiting should reserve roughly the space the finished content will occupy. A twelve pixel spinner replaced by four hundred pixels of answer is a layout jump, and it reads as jank even when the actual timing is good.

// app/chat/page.tsx
import { Suspense } from "react";

export default async function ChatPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q = "" } = await searchParams;

  return (
    <main className="mx-auto max-w-2xl p-6">
      {/* renders immediately, never waits on the model */m
      <PromptHeader query={q} />

      <Suspense fallback={<AnswerSkeleton />}>
        <ModelAnswer query={q} />
      </Suspense>
    </main>
  );
}

async function ModelAnswer({ query }: { query: string }) {
  const answer = await getAnswer(query);
  return <article className="prose">{answer}</article>;
}

function AnswerSkeleton() {
  // same footprint as a typical answer, so nothing shifts on swap
  return <div className="min-h-64 animate-pulse rounded-xl bg-neutral-100" />;
}
Enter fullscreen mode Exit fullscreen mode

PromptHeader is on screen in the first flush. ModelAnswer arrives when it arrives. That is the entire trick.


Partial Prerendering in Next.js 15 and when it helps AI apps

Partial Prerendering in Next.js 15 combines a static shell with dynamic server component streaming, which improves Time to First Byte. For a chat or answer page this fits almost too neatly, because the shell genuinely is static: nav, heading, input, empty state, footer. Only the answer changes.

When it helps: your page has a real static skeleton and one clearly dynamic hole. The shell can be served from the edge cache while the model output streams into the boundary.

When it buys you nothing: the page is dynamic top to bottom. A personalised dashboard where the greeting, the usage counters, the recent items and the answer are all user specific has no static shell to prerender, so you are paying configuration complexity for a cached wrapper that is mostly empty.

Be honest about which one you have. I have watched people enable PPR on a fully dynamic route, measure nothing, and conclude the feature does not work. It worked fine. There was just nothing static to hoist.


The sibling component pattern for parallel data fetching

Once one boundary works, the instinct is to keep adding awaits to the same component. Resist it. Sequential awaits in one parent are the most common source of "why is this page four seconds slower than the slowest call in it".

// sequential: every await blocks the next one, and the whole tree below
export default async function Page() {
  const profile = await getProfile();   // 400ms
  const history = await getHistory();   // 300ms
  const answer  = await getAnswer();    // 6s
  return <Layout profile={profile} history={history} answer={answer} />;
}
Enter fullscreen mode Exit fullscreen mode

Split it into siblings instead. Each one owns its own fetch, each one gets its own boundary, and React streams whichever finishes first:

export default function Page() {
  return (
    <main>
      <Suspense fallback={<Skeleton height={120} />}>
        <Profile />
      </Suspense>

      <Suspense fallback={<Skeleton height={180} />}>
        <History />
      </Suspense>

      <Suspense fallback={<AnswerSkeleton />}>
        <ModelAnswer />
      </Suspense>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Profile and history land almost instantly, the answer takes its six seconds, and the reader has something to look at the whole time. Total wait is the slowest fetch, not the sum. Note that the parent is no longer async: the moment you put an await back at the top, you have quietly serialised everything under it again.


Common mistakes: blocking RSC with client side AI calls

Three mistakes account for most of the streaming bugs I have had to unpick.

Calling the model from useEffect. The page gets marked as a client component, so nothing happens until the browser downloads the JavaScript, parses it, hydrates the tree, and runs the effect. Only then does the request to your model even start. You have added the entire hydration cost in front of a call that was already the slowest thing on the page. Move it to a server component and let the request start while the HTML is still streaming.

Buffering the whole response before rendering. Collecting chunks into a string and setting state once at the end is a very natural thing to write, and it throws away streaming completely. The tokens arrived early. You chose to sit on them. Render each chunk as it lands.

Putting use client too high in the tree. That directive is contagious downward: everything imported below it becomes client code. One interactive button at the top of a layout can drag the entire route to the client, and then your careful server side boundaries stop meaning anything. Keep the directive on the smallest interactive leaf you can, and pass server rendered children into it rather than importing them below it.


Testing streaming components without mocking the LLM

Mocking the SDK tests your mock. What you actually want is a fake stream sitting behind the same interface the real call uses, emitting chunks on a timer.

// test/fake-stream.ts
export async function* fakeStream(chunks: string[], delayMs = 20) {
  for (const chunk of chunks) {
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    yield chunk;
  }
}
Enter fullscreen mode Exit fullscreen mode

Point your data function at that generator in tests and assert on what the reader sees over time, not just on the final string:

it("shows the first chunk before the stream finishes", async () => {
  render(await ModelAnswer({ query: "hello" }));
  expect(await screen.findByText(/Streaming/)).toBeInTheDocument();
  // stream is still open here
});
Enter fullscreen mode Exit fullscreen mode

Test the loading state with the same seriousness as the resolved state. The fallback is what the reader stares at for six seconds, so a broken skeleton is a more visible bug than a mistimed final render.


FAQ

How do I stream AI responses in Next.js?

Put the model call in an async server component, wrap that component in a Suspense boundary with a realistic fallback, and let the App Router stream the rest of the page immediately. Tokens reach the browser as the model produces them, so the reader sees output within the first network round trip rather than after the full generation completes.

What is the best pattern for AI streaming with React Server Components?

One boundary around the slow model call, and siblings for anything else that fetches. Keeping each fetch in its own component with its own boundary means the total wait equals the slowest fetch instead of the sum of all of them, and it stops one slow call from holding the rest of the page hostage.

How does Partial Prerendering work with AI apps?

Partial Prerendering serves a static shell and streams the dynamic server components into it, which improves Time to First Byte. It pays off when your page has a genuinely static skeleton around one dynamic hole. On a route that is dynamic all the way down, there is no shell to prerender and you will not see much.


Most of this came out of shipping Next.js for AI products, where the streaming UI is usually the part that decides whether the whole thing feels fast.

If your slow call is a retrieval step rather than the model itself, the production RAG guide covers where that latency actually goes.


Drop a comment if your setup looks different. Curious how people are handling boundaries on pages with more than one model call in flight.

Top comments (0)