Skip to main content
Back to Blog
Next.jsReact 19Server ActionsShadcn UIAI StreamingSSE

How to Build a Real-Time AI Streaming UI with Next.js App Router, Server Actions, and Shadcn UI

Learn how to implement token-by-token AI response streaming in Next.js using Server-Sent Events, React 19 hooks like useOptimistic and useTransition, and a fully accessible Shadcn UI chat interface.

September 2, 202616 min readNiraj Kumar

Introduction

If you've shipped an AI feature in the last couple of years, you already know the single biggest UX killer: the spinner. A user hits "generate," stares at a loading indicator for four to eight seconds, and then gets a wall of text dumped on them all at once. It feels slow, even when the total latency is technically acceptable, because there's no feedback loop. Compare that to ChatGPT, Claude, or Perplexity, where text starts appearing within a second and keeps flowing — the perceived performance is night and day, even if the total generation time is identical.

That perception gap is exactly what streaming solves. Instead of waiting for the full LLM response and shipping it as one payload, you push tokens to the client as they're generated. The browser renders them incrementally, and the user gets immediate, continuous feedback.

This guide walks through building that experience end-to-end in a modern Next.js App Router project, using:

  • Server Actions to trigger and manage the AI generation on the server
  • Server-Sent Events (SSE) semantics for delivering incremental chunks to the client
  • React 19 hooks (useOptimistic, useTransition, and use) to keep the UI responsive without manual state gymnastics
  • Shadcn UI to build a chat interface that's accessible, themeable, and doesn't look like a demo project

By the end, you'll have a reusable streaming chat pattern you can drop into a support bot, a docs assistant, a code copilot, or any product surface where an LLM talks back to your users.


Why Streaming Matters (And Why It's Harder Than It Looks)

Before touching code, it's worth understanding why streaming is architecturally different from a normal request/response cycle, because that difference dictates every decision you'll make later.

A typical API call is atomic: you send a request, the server does work, and you get one response back. Streaming breaks that contract. The server starts sending data before it has finished working, and the client has to be able to:

  1. Receive a sequence of partial chunks over an open connection
  2. Append each chunk to some kind of accumulating state
  3. Re-render the UI on every chunk without janking the scroll position or losing focus on the input field
  4. Handle the "done" signal gracefully, including errors that occur mid-stream

None of this is exotic technology — SSE has existed since HTML5 — but doing it cleanly inside a React Server Components architecture, where the server and client boundary is blurred by design, requires understanding a few new primitives that didn't exist before React 19 and Next.js's App Router.

SSE vs. WebSockets: Why SSE Wins for AI Chat

It's tempting to reach for WebSockets because they feel like the "real-time" tool. For LLM streaming specifically, SSE is almost always the better choice:

  • Unidirectional by nature — the server pushes tokens to the client; you rarely need the client to push data back over the same connection mid-stream. WebSockets' bidirectional complexity buys you nothing here.
  • Built on plain HTTP — SSE rides on a standard HTTP response with Content-Type: text/event-stream. It plays nicely with existing infrastructure: load balancers, CDNs, proxies, and serverless platforms understand it without special configuration.
  • Automatic reconnection — the native EventSource API reconnects automatically on connection drops, something you'd have to hand-roll with WebSockets.
  • Simpler mental model — no socket lifecycle management, no ping/pong keep-alives, no separate protocol upgrade handshake.

WebSockets earn their complexity when you need true bidirectional, low-latency communication — collaborative cursors, multiplayer games, live trading data. For "server generates tokens, client displays them," SSE is the right level of abstraction.


The Architecture: How Server Actions Actually Stream

Here's the part that trips people up. When you hear "Server Actions" and "streaming" in the same sentence, it's natural to assume Next.js is spinning up a literal text/event-stream response under the hood. It isn't — and understanding what it actually does will save you a lot of debugging time.

Server Actions are RPC-like functions that execute on the server and get invoked from client components. Under the hood, Next.js uses React Server Components' native streaming protocol — the same chunked-transfer mechanism that lets RSC payloads render progressively. When a Server Action returns a special "streamable value" (a pattern popularized by the Vercel AI SDK's ai/rsc module), React keeps the connection open and pushes updates to that value over time, and the client can subscribe to those updates with an async iterator.

Functionally, this behaves almost identically to SSE from the user's perspective: chunks arrive incrementally, the UI updates token-by-token, and the "connection" closes when generation finishes. But it's not literal EventSource-based SSE — it's RSC streaming, which only works within the React Server Components boundary (i.e., you're calling the Server Action from a Client Component in the same Next.js app).

When you need real SSE — for example, a public API endpoint consumed by EventSource in a non-React client, a mobile app, or a third-party integration — you'll want a Route Handler that manually constructs a ReadableStream and returns it with the correct SSE headers. We'll cover both approaches below because production apps often need both: Server Actions for the in-app chat UI, and a Route Handler for anything that talks to your API from outside the RSC tree.


Setting Up the Project

Start with a fresh Next.js App Router project and add Shadcn UI along with the Vercel AI SDK (used here for its clean streamText abstraction over LLM providers — you can swap in any provider that exposes a token stream).

npx create-next-app@latest streaming-chat-demo --typescript --tailwind --app
cd streaming-chat-demo

npx shadcn@latest init
npx shadcn@latest add button textarea scroll-area avatar card

npm install ai @ai-sdk/openai

Set your API key in .env.local:

OPENAI_API_KEY=sk-your-key-here

Your folder structure should look roughly like this once we're done:

app/
  actions.ts          # Server Action for RSC-based streaming
  api/
    chat/
      route.ts         # Route Handler for true SSE
  page.tsx
components/
  chat/
    chat-window.tsx
    chat-message.tsx
  ui/                  # shadcn-generated primitives

Building the Streaming Server Action

This is the core of the RSC-based approach. We create a streamable value, kick off the LLM call inside an unawaited async IIFE, and push each delta into the stream as it arrives.

// app/actions.ts
'use server';

import { createStreamableValue } from 'ai/rsc';
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';

export async function streamChatResponse(prompt: string) {
  const stream = createStreamableValue('');

  (async () => {
    try {
      const { textStream } = streamText({
        model: openai('gpt-4o'),
        system: 'You are a concise, helpful assistant.',
        prompt,
      });

      for await (const delta of textStream) {
        stream.update(delta);
      }
    } catch (error) {
      stream.error(error instanceof Error ? error.message : 'Generation failed');
    } finally {
      stream.done();
    }
  })();

  return { output: stream.value };
}

A few details matter here:

  • The async IIFE runs detached from the returned value. The function returns { output: stream.value } immediately, while the generation loop continues running in the background and pushing updates into that value. This is what allows the client to start consuming chunks right away instead of waiting for the whole function to resolve.
  • Always wrap the loop in try/catch/finally. If the upstream LLM call throws (rate limit, network blip, invalid API key), you want to surface that to the client gracefully rather than leaving the stream hanging open forever.
  • stream.done() is not optional. Forgetting to call it is one of the most common bugs in this pattern — the client-side iterator will just hang, and your loading spinner will spin forever.

Consuming the Stream on the Client with React 19 Hooks

This is where React 19's newer hooks genuinely simplify what used to be a mess of manual state management.

useOptimistic for Instant Feedback

When a user sends a message, you don't want to wait for any server round-trip before showing it in the chat log. useOptimistic lets you render the user's message immediately, then reconciles it once the real state updates.

useTransition for Non-Blocking Updates

Because streaming involves an async loop that updates state dozens of times per second, wrapping it in startTransition keeps those updates from blocking more urgent UI work (like the input field staying responsive while text streams in).

Here's the full chat window component, built on Shadcn UI primitives:

// components/chat/chat-window.tsx
'use client';

import { useOptimistic, useRef, useState, useTransition } from 'react';
import { readStreamableValue } from 'ai/rsc';
import { streamChatResponse } from '@/app/actions';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Loader2, SendHorizontal } from 'lucide-react';

type Message = {
  id: string;
  role: 'user' | 'assistant';
  content: string;
};

export function ChatWindow() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const [isPending, startTransition] = useTransition();
  const scrollRef = useRef<HTMLDivElement>(null);

  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage: Message) => [...state, newMessage],
  );

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const trimmed = input.trim();
    if (!trimmed || isPending) return;

    const userMessage: Message = {
      id: crypto.randomUUID(),
      role: 'user',
      content: trimmed,
    };
    const assistantId = crypto.randomUUID();

    setInput('');

    startTransition(async () => {
      addOptimisticMessage(userMessage);
      setMessages((prev) => [
        ...prev,
        userMessage,
        { id: assistantId, role: 'assistant', content: '' },
      ]);

      const { output } = await streamChatResponse(trimmed);

      for await (const delta of readStreamableValue(output)) {
        setMessages((prev) =>
          prev.map((m) =>
            m.id === assistantId ? { ...m, content: m.content + (delta ?? '') } : m,
          ),
        );
        scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
      }
    });
  }

  return (
    <div className="flex h-[600px] w-full max-w-2xl flex-col rounded-lg border bg-background shadow-sm">
      <ScrollArea
        className="flex-1 p-4"
        ref={scrollRef}
        aria-live="polite"
        aria-relevant="additions"
        aria-label="Chat conversation"
      >
        <div className="flex flex-col gap-4">
          {optimisticMessages.map((message) => (
            <div
              key={message.id}
              className={`flex items-start gap-3 ${
                message.role === 'user' ? 'flex-row-reverse' : ''
              }`}
            >
              <Avatar className="h-8 w-8 shrink-0">
                <AvatarFallback>{message.role === 'user' ? 'U' : 'AI'}</AvatarFallback>
              </Avatar>
              <div
                className={`max-w-[75%] rounded-lg px-4 py-2 text-sm leading-relaxed ${
                  message.role === 'user'
                    ? 'bg-primary text-primary-foreground'
                    : 'bg-muted text-foreground'
                }`}
              >
                {message.content || (
                  <Loader2 className="h-4 w-4 animate-spin" aria-label="Generating response" />
                )}
              </div>
            </div>
          ))}
        </div>
      </ScrollArea>

      <form onSubmit={handleSubmit} className="flex items-end gap-2 border-t p-3">
        <Textarea
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Ask me anything..."
          className="min-h-[44px] resize-none"
          aria-label="Chat message input"
          onKeyDown={(e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
              e.preventDefault();
              handleSubmit(e as unknown as React.FormEvent);
            }
          }}
        />
        <Button type="submit" size="icon" disabled={isPending} aria-label="Send message">
          {isPending ? (
            <Loader2 className="h-4 w-4 animate-spin" />
          ) : (
            <SendHorizontal className="h-4 w-4" />
          )}
        </Button>
      </form>
    </div>
  );
}

Notice the aria-live="polite" and aria-relevant="additions" on the scroll container. This is what makes the streaming text announce itself to screen readers as it arrives, without interrupting the user mid-sentence the way aria-live="assertive" would. We'll dig deeper into accessibility shortly.


The Route Handler Alternative: True SSE

If you need a genuine text/event-stream endpoint — say, for a public API, a webhook consumer, or a non-Next.js client using the native EventSource API — build a Route Handler that manually constructs the stream:

// app/api/chat/route.ts
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';

export async function POST(req: Request) {
  const { prompt } = await req.json();
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      try {
        const { textStream } = streamText({
          model: openai('gpt-4o'),
          prompt,
        });

        for await (const chunk of textStream) {
          const payload = `data: ${JSON.stringify({ text: chunk })}\n\n`;
          controller.enqueue(encoder.encode(payload));
        }

        controller.enqueue(encoder.encode('data: [DONE]\n\n'));
      } catch (err) {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ error: 'stream_failed' })}\n\n`),
        );
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      Connection: 'keep-alive',
      'X-Accel-Buffering': 'no',
    },
  });
}

A client consuming this with EventSource might look like:

const source = new EventSource('/api/chat?prompt=hello');

source.onmessage = (event) => {
  if (event.data === '[DONE]') {
    source.close();
    return;
  }
  const { text } = JSON.parse(event.data);
  appendToUI(text);
};

Note the X-Accel-Buffering: no header — this disables response buffering on reverse proxies like Nginx that would otherwise batch your chunks and defeat the entire purpose of streaming. It's a small header that causes a surprisingly large number of "streaming works locally but not in production" bug reports.


Real-World Example: Adding Stop and Retry Controls

A production chat UI needs more than happy-path streaming. Users expect to interrupt a long generation or retry a failed one. Here's how to extend the Server Action pattern with an AbortController-friendly stop button, using a ref to track the active generation:

const controllerRef = useRef<{ stopped: boolean }>({ stopped: false });

function handleStop() {
  controllerRef.current.stopped = true;
}

// inside the streaming loop
for await (const delta of readStreamableValue(output)) {
  if (controllerRef.current.stopped) break;
  setMessages((prev) =>
    prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + (delta ?? '') } : m)),
  );
}
controllerRef.current.stopped = false;

This won't cancel the upstream LLM call (that requires plumbing an AbortSignal into streamText itself and tearing down the connection server-side, which the Vercel AI SDK supports directly), but it immediately stops rendering further tokens and gives users the responsive "stop generating" behavior they expect.


Accessibility: Don't Ship a Streaming UI Screen Readers Can't Use

It's easy to build a beautiful streaming chat that's completely unusable with assistive technology. A few non-negotiables:

  • Use aria-live="polite" on the message container, not assertive. Assertive live regions interrupt whatever the screen reader is currently announcing, which is jarring when text is arriving multiple times per second.
  • Avoid re-announcing the entire message on every token. Some screen readers will re-read the full aria-live region content on every DOM mutation. Debounce announcements or batch updates (e.g., flush accumulated text every 200–300ms) rather than updating state on every single token.
  • Keep focus in the input field after submission. Don't programmatically move focus into the message list — that breaks the natural flow for keyboard and screen reader users who expect to keep typing.
  • Label the loading state. The spinner shown while message.content is empty needs an aria-label (as in the example above) so it isn't silently skipped.
  • Respect prefers-reduced-motion for any typing-indicator animations or auto-scroll behavior.

Shadcn UI's primitives (built on Radix UI) already handle a lot of the keyboard navigation and focus management for components like ScrollArea and Button, but the live-region behavior for streaming text is entirely your responsibility — Radix doesn't know your app streams text.


Best Practices

  • Debounce state updates for very fast streams. If your LLM emits tokens faster than the browser can meaningfully repaint (common with smaller, faster models), batch several deltas together before calling setMessages to avoid excessive re-renders.
  • Persist partial messages. If a user navigates away mid-stream, decide deliberately whether to keep, discard, or resume that generation — don't let it silently vanish.
  • Always pair streaming with a non-streaming fallback. Some corporate proxies and browser extensions interfere with streaming responses. Detect a stalled stream (no chunk received within N seconds) and fall back to a standard polling or full-response request.
  • Sanitize and render Markdown incrementally. LLM output is usually Markdown. Use a streaming-aware renderer (or debounce full re-parses) rather than re-parsing the entire Markdown string on every token, which gets expensive fast on long responses.
  • Separate your streaming transport from your UI state. Keep the Server Action/Route Handler logic agnostic of how the UI renders messages — this makes it far easier to swap providers or add multi-turn context later.
  • Rate-limit at the server action level, not just at the UI. A disabled button is not a security boundary; validate on the server.

Common Mistakes

  • Forgetting stream.done() or controller.close(). This leaves the client-side async iterator waiting forever, manifesting as a spinner that never resolves.
  • Assuming Server Actions produce literal EventSource-compatible SSE. They don't — they use RSC's internal streaming protocol. If you need a public, framework-agnostic streaming endpoint, use a Route Handler instead.
  • Updating React state on every single token without batching. On a fast stream, this can produce hundreds of re-renders per second and visibly janky scrolling.
  • Not handling proxy buffering in production. A stream that works perfectly on localhost but arrives in one giant chunk in production is almost always a missing X-Accel-Buffering: no header or a CDN caching the response.
  • Losing the user's scroll position. Auto-scrolling to the bottom on every token is jarring if the user has scrolled up to read earlier messages — only auto-scroll if they're already near the bottom.
  • Ignoring error states mid-stream. A dropped connection or an upstream API error should surface a clear, recoverable message in the chat — not fail silently or leave a half-finished sentence with no explanation.

🚀 Pro Tips

  • Use structuredClone-safe, serializable message objects if you plan to persist chat history to a database mid-stream — avoid storing class instances or functions in your message state.
  • When testing streaming locally, throttle your network in DevTools to "Slow 3G" occasionally. It exposes race conditions and janky re-renders that a fast local connection hides completely.
  • If you're streaming structured data (JSON, tool calls) rather than plain text, stream partial JSON fragments and use a tolerant streaming JSON parser rather than trying to JSON.parse an incomplete string on every chunk.
  • Add a subtle blinking cursor (a simple CSS ::after pseudo-element) at the end of the assistant's message while streaming is active — it's a small touch that dramatically improves the perceived "liveness" of the UI.
  • Cache the ReadableStream reader instance per request; don't recreate the LLM client on every keystroke if you're also implementing live "typing preview" features.

📌 Key Takeaways

  • Streaming isn't just a nice-to-have — it directly improves perceived performance, and users consistently report AI features as "faster" when responses stream, even at identical total latency.
  • Server Actions combined with ai/rsc's streamable values give you SSE-like behavior without leaving the React Server Components boundary, which is ideal for in-app chat UIs.
  • For public APIs or non-React clients, build a dedicated Route Handler that returns a real text/event-stream response using ReadableStream.
  • React 19's useOptimistic and useTransition hooks remove most of the manual state-juggling that used to make streaming UIs error-prone.
  • Accessibility is not optional: aria-live="polite", debounced announcements, and stable keyboard focus are what separate a demo from a production-ready feature.

Conclusion

Streaming AI responses used to require hand-rolled WebSocket servers or brittle polling loops. With Next.js's App Router, Server Actions, and React 19's newer hooks, the entire pattern collapses into a handful of well-understood primitives: a streamable value on the server, an async iterator on the client, and a couple of hooks to keep the UI responsive while it all happens.

The architecture you choose — RSC-based Server Actions versus a dedicated SSE Route Handler — depends entirely on who's consuming the stream. If it's your own Next.js frontend, lean on Server Actions and skip the boilerplate of manual ReadableStream construction. If you're exposing a public or cross-platform API, build the Route Handler and give clients a standards-compliant text/event-stream endpoint they can consume with nothing more than the native EventSource API.

Either way, pair it with a genuinely accessible Shadcn UI chat component, and you'll have shipped an AI feature that doesn't just work — it feels fast, feels alive, and feels like the products your users already trust.


References

Discussion

All Articles
Next.jsReact 19Server ActionsShadcn UIAI StreamingSSE

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.