Skip to content

Chapter 9: Generative UI - Streaming React Components (RSC)

Theoretical Foundations

In traditional web development, the relationship between the server and the client is rigid. The server sends a static HTML document or a JSON payload, and the client-side JavaScript (React, Vue, etc.) consumes that data to render a pre-determined UI. The UI components themselves are fixed; they exist in the codebase before the request is even made. The server tells the client what happened, and the client decides how to show it.

Generative UI shatters this boundary. It introduces a radical shift: the server no longer just sends data; it sends the UI itself. It generates React components on the fly, tailored to the specific context of the user's request, and streams them directly to the client.

To understand this, let's use an analogy: The Restaurant vs. The Personal Chef.

  • Traditional Web Apps (The Restaurant): You (the user) order from a fixed menu (the pre-built components). The kitchen (the server) prepares standard dishes (JSON data) and sends them out. You can customize the presentation slightly (CSS), but you cannot ask for a dish that isn't on the menu. If you want a new dish, the kitchen staff must write a new recipe, build it, and add it to the menu (a new deployment).

  • Generative UI (The Personal Chef): You describe your craving and dietary needs (the prompt). The chef (the LLM) doesn't just cook a known recipe. They look at the ingredients available, your preferences, and the occasion, then invent a dish (the React component) specifically for you. They don't just send the final plate; they plate it piece by piece, streaming the presentation to your table in real-time. If you want to see the dish arranged differently, they can adapt and regenerate the plating on the fly.

This chapter explores the architecture of this "Personal Chef" model, specifically using React Server Components (RSC) as the plating mechanism and LangChain.js as the creative engine.

The Atomic Unit: The Thought-Action-Observation Loop

Before we can stream UI, we must understand how the "chef" thinks. In the previous chapter, we established the Thought-Action-Observation Triple as the core engine of the ReAct framework. This is the cognitive process that drives the generative act.

Let's break this down with a Software Bug Tracker Analogy.

Imagine a developer (the LLM) is assigned a complex bug ticket (the user prompt). They cannot solve it in one go. They must break it down:

  1. Thought (Internal Reasoning): "The user is reporting that their dashboard isn't loading data. I suspect the API call is failing. I need to check the server logs first to see if there are any errors."
    • This is the LLM's internal monologue, planning its next move.
  2. Action (Tool Call): The developer opens a terminal and runs a script to fetch logs for the specific service. This is a discrete, executable task. In our world, this is calling a function like fetchUserAnalyticsData() or searchKnowledgeBase().
    • This is the LLM deciding to use a tool.
  3. Observation (Tool Result): The terminal outputs: Error: 500 Internal Server Error, Database connection timeout.
    • This is the raw data returned from the tool.

This cycle repeats. The next Thought might be: "Okay, the database is timing out. I need to check if the user's query is too large." The next Action might be to call validateQueryComplexity(userQuery). The Observation would be the result.

In the context of Generative UI, this loop is supercharged. The "Action" is no longer just fetching data; it can be generating a UI component. The "Observation" is not just text or JSON; it's the generated TSX code block that represents a piece of the user interface.

The Conduit: Streaming React Server Components (RSC)

Now that we have a thinking engine, we need a way to transport its creations to the user. This is where React Server Components become the critical infrastructure.

In the App Router, RSCs allow components to be rendered exclusively on the server. The output of this rendering is a special binary format (a subset of JSON) that describes the React component tree. This format is highly efficient and can be streamed.

Analogy: The Assembly Line Conveyor Belt.

Imagine a factory assembly line (the network stream). At the start of the line, the LLM is the designer. Instead of designing the entire car and then shipping it, the designer sends instructions for one part at a time.

  • The LLM generates a <SalesChart /> component.
  • This component is serialized into the RSC format and immediately sent down the conveyor belt (the network stream).
  • The client (the factory floor) receives this part. It doesn't wait for the whole car. It immediately starts rendering the <SalesChart /> and showing it to the user.
  • While the user is already looking at the chart, the LLM is generating the next part: a <FeedbackForm />. This is serialized and sent down the same belt.
  • The client receives it and slots it into place next to the chart.

This is fundamentally different from streaming text. When you stream text, you are just appending strings. With RSC streaming, you are streaming executable UI logic. The client receives a blueprint for a component, hydrates it, and makes it interactive (if it's a client component) or keeps it as a static element (if it's a server component).

The Orchestrator: The Generative UI Loop

Let's synthesize the "Thinking" (ReAct) and the "Streaming" (RSC) into a single, cohesive mental model. This is the core theoretical loop of Generative UI.

Analogy: The Architect and the Construction Crew.

The LLM is the Architect. The client is the Construction Crew standing on the site (the browser viewport). The user is the client who keeps changing their mind about the building.

  1. The Prompt (The Request): The user says, "I need a dashboard to analyze my sales data."
  2. Initial Thought: The Architect (LLM) thinks, "I don't have the data yet. I need to get the sales figures first."
  3. Action 1 (Data Fetch): The Architect calls the getSalesFigures() tool. This is an Action.
  4. Observation 1 (Raw Data): The tool returns { "Q1": 10000, "Q2": 15000, "Q3": 12000 }. This is the raw material.
  5. Thought 2: "Okay, I have the data. The user asked for a 'dashboard'. A good way to visualize this quarterly data is a bar chart. I will generate a BarChart component."
  6. Action 2 (Generate UI): The Architect designs the BarChart component. This is a new kind of Action. It's not just a tool call; it's a creative act that produces a React component.
  7. Streaming to the Crew: The Architect immediately sends the blueprint for the BarChart down to the Construction Crew (the client). The Crew starts building it right away. The user sees a chart appear.
  8. Observation 2 (The Rendered UI): The Crew signals back, "The chart is up." (This is implicit in the RSC stream; the component is now part of the DOM).
  9. Thought 3: "The user might want to see the raw numbers too. I should also generate a DataSummary component."
  10. Action 3 (Generate UI): The Architect designs the DataSummary component and streams it down.

This loop continues. The key insight is that the Observation for one step becomes the context for the next Thought. The BarChart component is now part of the "state" of the UI. The LLM can decide to stream a new component that interacts with the existing one, or regenerate a component based on new user feedback.

Visualizing the Data Flow

To make this concrete, let's visualize the flow of data and control. The Thought-Action-Observation loop is happening on the server, but the results (the UI components) are being piped to the client.

A server-side Thought-Action-Observation loop processes AI reasoning and executes actions, piping the resulting UI components to the client for display.
Hold "Ctrl" to enable pan & zoom

A server-side `Thought-Action-Observation` loop processes AI reasoning and executes actions, piping the resulting UI components to the client for display.

The Role of Zod: The Quality Control Inspector

In our construction analogy, what happens if the Architect sends a blueprint with a door that opens into a wall? Or a window with no glass? This is a malformed component. In the world of Generative UI, the LLM can be unpredictable. It might generate a component that expects a user prop but receives null. It might generate invalid HTML structure.

This is where Zod enters the picture as the site's Quality Control Inspector.

Before a generated component is allowed to be streamed to the client, it must pass inspection. We define a Zod schema for the expected output of the component generation.

Analogy: The Blueprint Validator.

  1. The Specification (Zod Schema): We provide the Architect with a strict rule: "Any BarChart you design MUST have a data prop that is an array of objects, and each object MUST have a label (string) and a value (number)."

    // The Zod Schema (The Specification)
    import { z } from 'zod';
    
    const BarChartSchema = z.object({
      data: z.array(
        z.object({
          label: z.string(),
          value: z.number(),
        })
      ),
      title: z.string().optional(),
    });
    

  2. The Generated Blueprint (LLM Output): The LLM, in its Action step, generates a component. It might be:

    // The LLM's proposed component
    <BarChart data={[{ label: 'Q1', val: 10000 }]} title="Sales" />
    // Notice: 'val' instead of 'value'. This is a mistake.
    

  3. The Inspection (Zod Parsing): Before streaming, our server-side code passes the props generated by the LLM into the Zod schema.

    // Server-side validation logic
    const validationResult = BarChartSchema.safeParse(llmGeneratedProps);
    
    if (!validationResult.success) {
      // The Inspector stops the assembly line!
      // We can feed this error back to the LLM as a new Observation.
      // "Your last component failed validation: 'val' is not a valid key, expected 'value'."
      // The LLM then re-thinks and re-generates.
      console.error("Validation Failed:", validationResult.error);
    } else {
      // The blueprint is approved. Stream it to the client.
      streamComponent(<BarChart {...validationResult.data} />);
    }
    

This creates a powerful feedback loop. The Zod schema acts as a guardrail, ensuring that only valid, type-safe UI components are ever rendered on the client. It turns the probabilistic nature of the LLM into a deterministic, safe system.

Theoretical Foundations

We have established that Generative UI is not a single technology but an architectural pattern combining three distinct concepts:

  1. The ReAct Loop (The Brain): A cyclical process of reasoning and acting, which allows the LLM to break down complex UI requests into manageable steps (fetch data, generate chart, generate table, etc.).
  2. Streaming RSCs (The Conduit): A transport mechanism that allows the server to send executable UI components to the client progressively, enabling a fluid, real-time user experience.
  3. Zod Validation (The Safety Net): A schema-based validation layer that ensures the LLM's generated UI components are structurally sound and type-safe before they are ever rendered.

Together, these foundations allow us to move from generating static text to dynamically creating interactive, component-based user interfaces on the fly.

Basic Code Example

This example demonstrates the foundational pattern of Generative UI. We will build a simple SaaS-style dashboard where a server-side AI model dynamically generates a React component (a "WelcomeCard") and streams it directly to the client. The client receives the raw component definition (JSX-like structure) as a stream, reconstructs it, and renders it.

Architecture Overview: 1. Server Component: Fetches context and initiates the generation stream. 2. Server Action: Handles the LangChain stream, parses the output, and yields component chunks. 3. Client Component: Consumes the stream and renders the UI incrementally.

The Code

// File: components/ai-dashboard.tsx
'use client';

import { useState, useEffect, useCallback } from 'react';

/**
 * @description Represents the structure of a generic React element generated by the AI.
 * In a real app, this would be a Zod schema for validation.
 */
type AIGeneratedElement = {
  type: 'div' | 'h1' | 'p' | 'button';
  props: {
    className?: string;
    children?: string | AIGeneratedElement[];
  };
};

/**
 * @description Client-side component that consumes the stream.
 * It maintains a local state of the UI being built.
 */
export default function AIDashboard() {
  const [uiTree, setUiTree] = useState<AIGeneratedElement | null>(null);
  const [isGenerating, setIsGenerating] = useState(false);

  /**
   * @description Triggers the server action to start streaming the component.
   * Uses useCallback to prevent unnecessary re-renders.
   */
  const generateUI = useCallback(async () => {
    setIsGenerating(true);
    setUiTree(null); // Reset previous UI

    // 1. Call the Server Action
    const response = await fetch('/api/generate-component', {
      method: 'POST',
    });

    if (!response.body) {
      console.error('No response body');
      return;
    }

    // 2. Create a reader for the stream
    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    // 3. Read the stream chunk by chunk
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value, { stream: true });

      // In a real scenario, we might parse JSON chunks here. 
      // For this "Hello World", we assume the stream sends a full JSON object 
      // representing a single component update.
      try {
        const parsedComponent = JSON.parse(chunk) as AIGeneratedElement;
        // Update state with the new component tree
        setUiTree(parsedComponent);
      } catch (e) {
        // Ignore incomplete JSON chunks (common in streaming)
      }
    }

    setIsGenerating(false);
  }, []);

  /**
   * @description Recursive renderer for the component tree.
   * Transforms the JSON-like structure into actual React elements.
   */
  const renderNode = (node: AIGeneratedElement, index: number) => {
    const { type, props } = node;

    // Handle children recursively
    const children = Array.isArray(props.children) 
      ? props.children.map((child, i) => renderNode(child, i))
      : props.children;

    // Create the React element
    return React.createElement(type, { key: index, ...props }, children);
  };

  return (
    <div className="p-6 border rounded-lg shadow-sm bg-white">
      <div className="flex justify-between items-center mb-4">
        <h2 className="text-xl font-bold">Generative UI Dashboard</h2>
        <button
          onClick={generateUI}
          disabled={isGenerating}
          className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
        >
          {isGenerating ? 'Generating...' : 'Generate Welcome Card'}
        </button>
      </div>

      <div className="min-h-[100px] border border-dashed border-gray-300 p-4 rounded bg-gray-50">
        {uiTree ? (
          renderNode(uiTree)
        ) : (
          <p className="text-gray-400 text-center mt-8">
            Click the button to generate a UI component via AI stream.
          </p>
        )}
      </div>
    </div>
  );
}
// File: app/api/generate-component/route.ts
import { NextResponse } from 'next/server';
import { ChatOpenAI } from '@langchain/openai';
import { PromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';

/**
 * @description Server Action / API Route to handle the stream.
 * Note: In Next.js App Router, this is a Route Handler.
 */
export async function POST() {
  // 1. Initialize the Model (Streaming enabled)
  const model = new ChatOpenAI({
    model: 'gpt-3.5-turbo',
    temperature: 0,
    streaming: true, // CRITICAL: Enables the stream
  });

  // 2. Define a prompt that forces JSON output representing a React component
  const prompt = PromptTemplate.fromTemplate(
    `You are a UI generator. Respond ONLY with a JSON object representing a React component.
     Do not include markdown code blocks or explanations.
     Structure: { "type": "div", "props": { "className": "...", "children": [ ... ] } }

     Generate a simple "Welcome Card" component with a heading, a paragraph, and a button.
     Component:`
  );

  // 3. Chain the prompt, model, and parser
  const chain = prompt.pipe(model).pipe(new StringOutputParser());

  // 4. Create a TransformStream to pipe the response
  const { readable, writable } = new TransformStream();

  // 5. Start the generation in a non-blocking way
  (async () => {
    const writer = writable.getWriter();
    const encoder = new TextEncoder();

    try {
      // LangChain's streaming callback
      let accumulatedContent = '';

      // We use the stream method directly to intercept chunks
      await chain.stream({}, {
        callbacks: [
          {
            handleLLMNewToken(token: string) {
              accumulatedContent += token;
              // In a real app, we might parse partial JSON here.
              // For simplicity, we wait for the full response in this demo,
              // but typically we stream partial chunks as they arrive.
            },
            async handleLLMEnd() {
              // When the stream finishes, send the complete JSON
              // In a production app, you would stream partial UI updates (e.g., first the div, then the children)

              // Sanitize the response (remove markdown artifacts if any)
              let cleanJson = accumulatedContent.trim();
              if (cleanJson.startsWith('```json')) {
                cleanJson = cleanJson.replace(/```json/g, '').replace(/```/g, '');
              }

              // Validate structure (simplified for demo)
              const componentData = {
                type: 'div',
                props: {
                  className: 'bg-white p-4 rounded shadow border border-gray-200',
                  children: [
                    {
                      type: 'h1',
                      props: { className: 'text-lg font-bold text-gray-800', children: 'Hello Generative UI!' }
                    },
                    {
                      type: 'p',
                      props: { className: 'text-sm text-gray-600 mt-2', children: 'This component was generated by LangChain.js on the server.' }
                    },
                    {
                      type: 'button',
                      props: { className: 'mt-3 px-3 py-1 bg-green-500 text-white text-xs rounded', children: 'Interact' }
                    }
                  ]
                }
              };

              // Write the final JSON to the stream
              await writer.write(encoder.encode(JSON.stringify(componentData)));
            }
          }
        ]
      });
    } catch (error) {
      console.error('Stream error:', error);
      // Handle error in stream
    } finally {
      await writer.close();
    }
  })();

  // Return the stream response
  return new Response(readable, {
    headers: {
      'Content-Type': 'application/json',
      'Transfer-Encoding': 'chunked',
    },
  });
}

Visualizing the Data Flow

The flow of data from the Server Component to the Client Component involves a continuous stream of JSON objects representing the UI tree.

This diagram illustrates the streaming of a JSON-based UI tree from the Server Component to the Client Component via a chunked Transfer-Encoding stream.
Hold "Ctrl" to enable pan & zoom

This diagram illustrates the streaming of a JSON-based UI tree from the Server Component to the Client Component via a chunked `Transfer-Encoding` stream.

Detailed Line-by-Line Explanation

1. Client Component (ai-dashboard.tsx)

  • 'use client';: This directive marks the file as a Client Component in the Next.js App Router. While we want to keep logic on the server, the rendering and stream consumption must happen here to update the DOM.
  • type AIGeneratedElement: We define a strict TypeScript interface for the JSON we expect to receive. This acts as a lightweight schema. In a production app, we would use Zod here to validate the incoming stream at runtime to prevent malformed UI from rendering.
  • generateUI function:
    • fetch('/api/generate-component'): We call our API route. Note that we are not using a Server Action directly here (like useActionState) because we need fine-grained control over the stream reader.
    • response.body.getReader(): This is the standard Web Streams API. It allows us to read the incoming bytes as they arrive, rather than waiting for the entire response to buffer.
    • decoder.decode(value, { stream: true }): Converts the Uint8Array stream chunks into strings.
    • JSON.parse(chunk): In this simplified example, the server sends one complete JSON object. In advanced Generative UI, the server might stream partial JSON (e.g., opening a <div> tag first). Parsing here reconstructs the UI tree.
  • renderNode function:
    • React.createElement(type, props, children): This is the core React API. Since we cannot use JSX directly on the arbitrary JSON data (we don't know the component types ahead of time), we use createElement to dynamically construct the React Element tree from the data structure.

2. Server Route (route.ts)

  • new ChatOpenAI({ streaming: true }): Instantiates the LangChain model with streaming enabled. This is crucial; without it, the model waits for the full response before returning.
  • TransformStream: This is a Web Stream primitive. It allows us to intercept the data flow from the model, process it, and pipe it to the client without buffering the entire response in memory.
  • chain.stream({}, callbacks):
    • We use the .stream() method provided by LangChain.
    • handleLLMNewToken: This callback fires every time the LLM generates a new token (word/part of word). In a complex app, we would accumulate these tokens and attempt to stream valid JSON objects incrementally.
    • handleLLMEnd: Called when the LLM finishes generation. In this "Hello World" example, we construct the final JSON object here.
  • writer.write(encoder.encode(...)): We encode our JSON string into bytes and write it to the writable stream, which pipes it to the client's response.body.

Common Pitfalls

  1. Vercel/AWS Lambda Timeouts (The 10s Wall)

    • Issue: Serverless functions have strict execution timeouts (often 10s on Vercel Hobby plans). If your LLM is slow or the chain is complex, the function might time out before the stream finishes.
    • Solution: Use LangChain's streaming (as shown above) to keep the execution alive by sending heartbeat chunks, or upgrade to a plan with longer timeouts. Do not await the full result; stream it.
  2. JSON Parsing in the Stream (The "SyntaxError: Unexpected token" Trap)

    • Issue: When streaming JSON, you rarely receive a valid JSON object in a single chunk. You might receive {"type": "div" in one chunk and "props": {}} in the next. Attempting to JSON.parse() every chunk will throw an error.
    • Solution:
      • Option A (Buffering): Accumulate chunks in a buffer on the client until a delimiter (like a newline \n) is found, then parse.
      • Option B (NDJSON): Use Newline Delimited JSON on the server. Send {...}\n for every complete object.
      • Option C (Zod + Streaming Parsers): Use libraries like jsonrepair or specific streaming JSON parsers to handle partial objects.
  3. React Server Components (RSC) vs. Client Components

    • Issue: Trying to use useEffect or useState inside a file that is only a Server Component (no 'use client' directive) will cause a build error.
    • Solution: The component consuming the stream must be a Client Component. However, the source of the stream (the API route or Server Action) should remain on the server. This hybrid architecture is the core of Generative UI.
  4. Async/Await Loops in Streams

    • Issue: Creating an infinite while(true) loop with await reader.read() can lead to "unhandled promise rejection" crashes if the client disconnects (closes the tab).
    • Solution: Always wrap stream reading in try/catch/finally blocks. Ensure the writer is closed (writer.close()) in the finally block to release resources and prevent memory leaks on the server.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon


Loading knowledge check...



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.