Generative UI replaces plain-text LLM responses with interface components that stream and render as they are generated. This guide builds that architecture end to end: an incremental JSON stream parser, a Zod-validated component contract, and hydration into type-safe React Server Component trees in Next.js, all without proprietary library lock-in.
How to Build Streaming Generative UI in Next.js
- Create an incremental JSON stream parser using the Web Streams
TransformStreamAPI with brace-depth counting. - Define a recursive, discriminated
UINodeZod schema as the contract between LLM output and React rendering. - Validate each parsed JSON object at the stream boundary using Zod's
safeParse, emitting typed error nodes on failure. - Build a component registry that maps each schema variant to a concrete React component with recursive tree rendering.
- Expose a Next.js Route Handler that calls the LLM API, strips SSE framing, and returns validated NDJSON to the client.
- Consume the stream in a client component using
useTransitionand progressivesetNodesbatching. - Prevent layout shifts with CSS
contain: layoutand dimension-matched skeleton placeholders. - Extend the system by adding a Zod variant, a React component file, and a registry map entry—no parser changes needed.
Generative UI changes the rendering target from post-hoc HTML to typed component trees. This article walks through building that architecture from scratch in Next.js 15+ with React 19, TypeScript, and Zod—no proprietary streaming libraries required.
Table of Contents
- Why Generative UI Needs a Streaming Architecture
- The Architecture at a Glance
- Building the Incremental JSON Stream Parser
- Defining the Component Schema Contract with Zod
- The Component Registry: Mapping Schemas to React Components
- Wiring It All Together in Next.js App Router
- Performance, Observability, and Production Hardening
- Owning Your Generative UI Stack
Why Generative UI Needs a Streaming Architecture
Generative UI changes the rendering target from post-hoc HTML to typed component trees. Rather than treating LLM output as raw text that gets shoved into a dangerouslySetInnerHTML call, generative UI treats model output as structured component trees, rendering interactive, type-safe React components directly from streaming JSON schemas. The dominant pattern today, where an LLM generates Markdown that gets converted post-hoc to HTML, carries serious architectural costs: layout shifts as content reflows during conversion, zero type safety at the rendering boundary, and no path to component-level interactivity like Server Actions or client-side event handlers.
The alternative is streaming structured JSON that maps directly to React components as tokens arrive. Each partial payload describes a typed component node with validated props, and those nodes mount in their final dimensions without reflowing the page.
This article walks through building that architecture from scratch in Next.js 15+ with React 19, TypeScript, and Zod. No ai/rsc, no Vercel AI SDK, no proprietary streaming libraries. The entire system rests on three core files plus five component implementations (provided in the repository): an incremental JSON stream parser (lib/json-stream-parser.ts), a schema-driven component registry (components/generative/registry.tsx), and a streaming UI view (app/dashboard/generative/page.tsx). Together, these form a complete demo application that can be cloned, pointed at any OpenAI-compatible endpoint, and extended with custom component types.
Prerequisites: Node.js 18.17+, Next.js 15.x, React 19, TypeScript 5.x, Zod 3.x, and an OpenAI-compatible API endpoint. The async
useTransitionpattern used later in this article requires React 19; it does not work in React 18.
The Architecture at a Glance
Data Flow: From LLM Token Stream to Rendered Component Tree
The data flow is linear and each stage has a single responsibility:
The LLM API emits a byte stream (NDJSON or SSE) as the model generates tokens. That byte stream enters json-stream-parser.ts, which incrementally accumulates chunks, attempts JSON parsing on each buffered payload, and yields successfully parsed objects. Those objects pass through Zod validation, where a recursive UINode schema confirms structural correctness and discriminates component types. Validated nodes feed into registry.tsx, which maps each schema variant to a concrete React component and recursively walks the tree to produce JSX. The resulting component tree renders progressively on the client, where interactive "use client" leaf components attach their event handlers and state.
Why Streaming JSON Beats Markdown-to-HTML
Markdown-to-HTML conversion produces flat, unstyled HTML that reflows as CSS loads and as subsequent tokens alter the document structure. Streaming structured components mount in their final dimensions because each component type carries its own layout constraints. A card component knows its width. A chart component reserves its height. Layout shifts shrink considerably, though skeleton dimensions need to stay in sync with each component's actual rendered size to fully eliminate reflow.
Zod validates every complete, buffered JSON object before passing it to the React renderer. If the LLM emits a
cardnode missing a requiredtitleprop, the validation layer catches it and emits a typed error component instead of letting a runtime exception crash the tree.
The less visible but more consequential advantage is type safety. Zod validates every complete, buffered JSON object before passing it to the React renderer. If the LLM emits a card node missing a required title prop, the validation layer catches it and emits a typed error component instead of letting a runtime exception crash the tree.
Composability is impossible with flat HTML because Markdown-derived markup cannot nest interactive components or carry complex props. With structured JSON, components nest naturally, carry arrays, objects, and enums as props, and trigger Server Actions. A generative action-button component can invoke a server-side mutation. None of that is structurally possible when you start from Markdown.
Key Constraints and Trade-offs
Partial JSON parsing adds per-object overhead proportional to payload size. The real latency cost is buffering: the parser accumulates enough bytes to form a valid JSON object before yielding, which means the first component renders only after the LLM has emitted a complete node descriptor. Careful prompt engineering mitigates this by instructing the model to emit small, self-contained nodes rather than one monolithic object.
The LLM needs to emit valid JSON component descriptors. OpenAI's structured output mode enforces valid JSON schemas, but not all models offer this feature, and those that do occasionally produce malformed output. Note that OpenAI's response_format: { type: "json_object" } guarantees the entire response is a single valid JSON object. It does not produce multiple newline-delimited objects, so it is not suitable for the NDJSON streaming approach used here. Components that require browser-side interactivity (click handlers, state, effects) need the "use client" directive and should be imported as leaves in the component tree. The generative architecture accounts for this boundary explicitly.
Building the Incremental JSON Stream Parser
Choosing Web Streams API Over Third-Party Libraries
The Web Streams API (ReadableStream, TransformStream, TextDecoderStream) provides everything needed for incremental JSON parsing without external dependencies. TransformStream is particularly well-suited: it accepts chunks from a readable source, applies a transformation, and exposes a new readable stream of transformed output. TextDecoderStream handles UTF-8 decoding of raw bytes.
Next.js 15 supports these APIs in both the Node.js and edge runtimes, making them a reliable foundation. No polyfills are required in Next.js 15's Node.js and edge runtimes. On the client, target Chrome 89+, Firefox 102+, or Safari 14.1+ for full Streams API support.
Important: If you are calling the OpenAI API with
stream: true, the response arrives as SSE (Server-Sent Events), where each line is prefixed withdata:and terminated with. The JSON parser below assumes raw JSON text (or NDJSON). You need to strip SSE framing before piping into the JSON parser. A minimal SSE-strippingTransformStreamstage is shown after the main parser.
lib/json-stream-parser.ts: Full Implementation
// lib/json-stream-parser.ts
import { z } from "zod";
const MAX_BUFFER_BYTES = 512_000;
/**
* Strips SSE framing ("data: " prefix, blank lines, "[DONE]" sentinel)
* and emits the raw JSON content strings.
*/
export function createSSEStripper(): TransformStream<string, string> {
let lineBuf = "";
return new TransformStream({
transform(chunk, controller) {
lineBuf += chunk;
const lines = lineBuf.split("
");
// Keep the last (potentially incomplete) line in the buffer
lineBuf = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === "" || trimmed === "data: [DONE]") continue;
if (trimmed.startsWith("data: ")) {
controller.enqueue(trimmed.slice(6));
}
}
},
flush(controller) {
const trimmed = lineBuf.trim();
if (trimmed.startsWith("data: ") && trimmed !== "data: [DONE]") {
controller.enqueue(trimmed.slice(6));
}
},
});
}
export function createJsonStreamParser<T>(
schema: z.ZodType<T>
): TransformStream<string, T | { type: "error"; message: string }> {
let buffer = "";
return new TransformStream({
transform(chunk, controller) {
buffer += chunk;
if (buffer.length > MAX_BUFFER_BYTES) {
controller.enqueue({
type: "error" as const,
message: "Buffer overflow: stream payload too large without a complete JSON object",
});
buffer = "";
return;
}
// Attempt to extract complete JSON objects from the buffer
let startIndex = 0;
while (startIndex < buffer.length) {
const objectStart = buffer.indexOf("{", startIndex);
if (objectStart === -1) break;
let braceDepth = 0;
let inString = false;
let escapeNext = false;
let objectEnd = -1;
for (let i = objectStart; i < buffer.length; i++) {
const char = buffer[i];
if (inString) {
if (escapeNext) {
// This character is escaped; consume it and clear the flag.
escapeNext = false;
continue;
}
if (char === "\\") {
escapeNext = true;
continue;
}
if (char === '"') {
inString = false;
}
continue;
}
// Not in a string
if (char === '"') {
inString = true;
continue;
}
if (char === "{") braceDepth++;
if (char === "}") {
braceDepth--;
if (braceDepth === 0) {
objectEnd = i;
break;
}
}
}
if (objectEnd === -1) {
// Incomplete object; retain the remainder in the buffer
buffer = buffer.slice(objectStart);
return;
}
const jsonString = buffer.slice(objectStart, objectEnd + 1);
startIndex = objectEnd + 1;
try {
const parsed = JSON.parse(jsonString);
const result = schema.safeParse(parsed);
if (result.success) {
controller.enqueue(result.data);
} else {
controller.enqueue({
type: "error" as const,
message: result.error.issues
.map((i) => i.message)
.join("; "),
});
}
} catch {
// JSON.parse failed on a brace-matched candidate; skip it.
continue;
}
}
buffer = buffer.slice(startIndex);
},
flush(controller) {
// Attempt to parse any remaining buffer content on stream close
if (buffer.trim().length > 0) {
try {
const parsed = JSON.parse(buffer.trim());
const result = schema.safeParse(parsed);
if (result.success) {
controller.enqueue(result.data);
}
} catch {
// Discard unparseable trailing content
}
}
},
});
}
export function createStreamPipeline<T>(
byteStream: ReadableStream<Uint8Array>,
schema: z.ZodType<T>
): ReadableStream<T | { type: "error"; message: string }> {
return byteStream
.pipeThrough(new TextDecoderStream())
.pipeThrough(createJsonStreamParser(schema));
}
The parser uses brace-depth counting with string-awareness to find complete JSON object boundaries within a continuous text stream. It tracks whether the current position is inside a JSON string literal (and whether the preceding character is an escape backslash) to avoid false matches on braces embedded in string values. When it finds a complete object, it runs JSON.parse followed immediately by Zod's safeParse, enqueuing either a validated result or a typed error object.
Note: The parser assumes it receives top-level JSON objects (NDJSON format). If the LLM emits wrapper envelopes like
{"wrapper": {"type":"card",...}}, the brace counter will extract the outermost object, not the inner one. Ensure the system prompt instructs the model to emit flat, top-level component objects.
Handling Partial and Malformed Payloads Gracefully
The core strategy is straightforward: attempt parse, and on failure, buffer more bytes and retry. The brace-counting approach means the parser never attempts JSON.parse on a substring it knows is incomplete. The edge cases that matter most are nested braces inside string values (handled by the inString flag) and escaped characters like \" (handled by the escapeNext flag).
// Demonstrating buffering recovery with two chunks
const chunk1 = '{"type":"card","props":{"title":"Metrics","val';
const chunk2 = 'ue":42}}';
// After chunk1: brace depth never returns to 0, so buffer retains everything.
// After chunk2: brace depth hits 0 at the final '}', yielding a valid object.
// Parser emits: { type: "card", props: { title: "Metrics", value: 42 } }
The parser does not attempt heuristic repairs like trimming trailing commas or inserting closing braces. Those techniques produce silent data corruption. The conservative approach (wait for a structurally complete object) is safer, and at typical LLM output rates of 50-100 tokens/s the buffering delay is unnoticeable.
Defining the Component Schema Contract with Zod
Designing a Recursive UINode Schema
The schema is the contract between the LLM's output format and React's rendering layer. It needs to be recursive (components contain children), discriminated (each type carries type-specific props), and strict. Zod rejects unknown properties, preventing the LLM from smuggling unexpected fields into your component tree.
// lib/ui-node-schema.ts
import { z } from "zod";
// 1. Declare the recursive type explicitly so z.lazy can reference it.
export type UINode =
| {
type: "heading";
props: { level: 1 | 2 | 3; text: string };
children?: UINode[];
}
| {
type: "card";
props: { title: string; value: string | number; trend?: "up" | "down" | "flat" };
children?: UINode[];
}
| {
type: "chart";
props: {
chartType: "bar" | "line" | "pie";
data: { label: string; value: number }[];
};
children?: UINode[];
}
| {
type: "action-button";
props: { label: string; action: string; payload?: Record<string, unknown> };
children?: UINode[];
}
| { type: "error"; message: string };
// 2. Define the schema using z.ZodType to break the recursive cycle.
const headingNode: z.ZodType<Extract<UINode, { type: "heading" }>> = z.object({
type: z.literal("heading"),
props: z.object({
level: z.union([z.literal(1), z.literal(2), z.literal(3)]),
text: z.string(),
}),
children: z.lazy(() => uiNodeSchema.array()).optional(),
});
const cardNode: z.ZodType<Extract<UINode, { type: "card" }>> = z.object({
type: z.literal("card"),
props: z.object({
title: z.string(),
value: z.union([z.string(), z.number()]),
trend: z.enum(["up", "down", "flat"]).optional(),
}),
children: z.lazy(() => uiNodeSchema.array()).optional(),
});
const chartNode: z.ZodType<Extract<UINode, { type: "chart" }>> = z.object({
type: z.literal("chart"),
props: z.object({
chartType: z.enum(["bar", "line", "pie"]),
data: z.array(z.object({ label: z.string(), value: z.number() })),
}),
children: z.lazy(() => uiNodeSchema.array()).optional(),
});
const actionButtonNode: z.ZodType<Extract<UINode, { type: "action-button" }>> = z.object({
type: z.literal("action-button"),
props: z.object({
label: z.string(),
action: z.string(),
payload: z.record(z.unknown()).optional(),
}),
children: z.lazy(() => uiNodeSchema.array()).optional(),
});
const errorNode: z.ZodType<Extract<UINode, { type: "error" }>> = z.object({
type: z.literal("error"),
message: z.string(),
});
export const uiNodeSchema: z.ZodType<UINode> = z.discriminatedUnion("type", [
headingNode,
cardNode,
chartNode,
actionButtonNode,
errorNode,
]);
This schema serves double duty. It validates LLM output at runtime, and it provides the TypeScript types that the component registry uses for prop narrowing. The explicit UINode type declaration breaks the recursive cycle that would otherwise prevent TypeScript from resolving z.lazy references. The z.lazy() call enables recursion: a card can contain action-button children, a heading can contain nested heading elements for sub-sections.
Validation at the Stream Boundary
The TransformStream validates each object immediately after JSON.parse succeeds. Use safeParse, never parse, in a streaming context. parse throws on validation failure, which would abort the entire stream and destroy all previously rendered components. safeParse returns a discriminated result object, allowing the parser to emit a typed error node and continue processing subsequent payloads.
When validation fails, the parser emits an error-type UINode. The component registry renders this as an inline error message rather than blanking the entire generative UI area, preserving components already on screen and giving the developer debugging prompts a clear signal about what went wrong.
The Component Registry: Mapping Schemas to React Components
components/generative/registry.tsx: Implementation
// components/generative/registry.tsx
"use client";
import React, { Suspense } from "react";
import type { UINode } from "@/lib/ui-node-schema";
// Static imports for lightweight components
import { GenHeading } from "./gen-heading";
import { GenCard } from "./gen-card";
import { GenError } from "./gen-error";
// Dynamic import for heavy components — React.lazy requires a client context
const GenChart = React.lazy(() => import("./gen-chart"));
// Interactive component (must also be "use client")
import { GenActionButton } from "./gen-action-button";
type UINodeType = UINode["type"];
const componentMap: Record<UINodeType, React.FC<any>> = {
heading: GenHeading,
card: GenCard,
chart: GenChart,
"action-button": GenActionButton,
error: GenError,
};
export function renderNode(node: UINode, key?: string | number): React.ReactNode {
const Component = componentMap[node.type];
if (!Component) {
return (
<GenError
key={key}
message={`Unknown component type: ${(node as any).type}`}
/>
);
}
const children =
"children" in node && node.children
? node.children.map((child, index) => renderNode(child, index))
: null;
if (node.type === "error") {
return <GenError key={key} message={node.message} />;
}
const props = "props" in node ? node.props : {};
// Only wrap lazy-loaded components in Suspense
if (node.type === "chart") {
return (
<Suspense key={key} fallback={<div className="skeleton skeleton-chart" />}>
<Component {...props}>
{children}
Component>
Suspense>
);
}
return (
<Component key={key} {...props}>
{children}
Component>
);
}
export function renderNodeTree(nodes: UINode[]): React.ReactNode {
return nodes.map((node, index) => renderNode(node, index));
}
The registry file is marked "use client" because it uses React.lazy(), which requires a client rendering context. The GenChart component uses React.lazy() for code-splitting since charting libraries are heavy and should not be in the initial bundle. Only the lazy-loaded GenChart is wrapped in Suspense; synchronous components render directly without Suspense overhead. The GenActionButton also needs the "use client" directive in its own file to enable interactive behavior (click handlers, state).
Note: The five leaf component files (
gen-heading.tsx,gen-card.tsx,gen-chart.tsx,gen-action-button.tsx,gen-error.tsx) need to exist in thecomponents/generative/directory.GenActionButtonrequires a"use client"directive. The repository contains complete implementations of all leaf components.
Recursive Tree Rendering with Type Narrowing
TypeScript's discriminated union narrowing ensures type safety flows through the recursive rendering. When renderNode checks node.type === "error", TypeScript narrows the type to the error variant, confirming the message property exists. For nodes with props, the spread {...props} passes only the props defined in that variant's schema.
The renderNode function calls itself for each child in the children array, producing a nested component tree of arbitrary depth. This recursion is bounded by the actual depth of the LLM's output, which prompt engineering keeps shallow in practice.
Registering New Component Types Without Changing Core Logic
Extending the system with a new component type requires exactly three additions: a new Zod variant in the uiNodeSchema discriminated union, a new React component file, and a new entry in componentMap. The parser, validation layer, and recursive renderer require zero modifications. Adding a type touches three files; the parser and renderer stay unchanged.
Wiring It All Together in Next.js App Router
The Route Handler: Calling the LLM and Returning a Stream
Server Actions in Next.js return serializable values, and ReadableStream is not serializable. Instead, use a Route Handler that returns a streaming Response object, which the client page fetches directly.
// app/api/generative/route.ts
import { createSSEStripper, createJsonStreamParser } from "@/lib/json-stream-parser";
import { uiNodeSchema } from "@/lib/ui-node-schema";
import { z } from "zod";
const LLM_API_URL = process.env.LLM_API_URL;
const LLM_API_KEY = process.env.LLM_API_KEY;
const LLM_MODEL = process.env.LLM_MODEL || "gpt-4o";
const promptSchema = z.object({
prompt: z.string().min(1).max(4096),
});
export async function POST(request: Request) {
if (!LLM_API_URL || !LLM_API_KEY) {
return new Response("LLM API not configured", { status: 503 });
}
const bodyResult = promptSchema.safeParse(await request.json().catch(() => null));
if (!bodyResult.success) {
return new Response("Invalid request body", { status: 400 });
}
const { prompt } = bodyResult.data;
const ac = new AbortController();
const timeout = setTimeout(() => ac.abort(), 30_000);
// Also abort if the client disconnects
request.signal.addEventListener("abort", () => ac.abort());
let llmResponse: Response;
try {
llmResponse = await fetch(LLM_API_URL, {
method: "POST",
signal: ac.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${LLM_API_KEY}`,
},
body: JSON.stringify({
model: LLM_MODEL,
stream: true,
messages: [
{
role: "system",
content: `You are a UI generator. Emit one JSON object per UI component, one per line (NDJSON format). Each object must have "type" (heading|card|chart|action-button) and "props".`,
},
{ role: "user", content: prompt },
],
}),
});
} catch {
clearTimeout(timeout);
return new Response("LLM API unreachable", { status: 502 });
}
if (!llmResponse.ok || !llmResponse.body) {
clearTimeout(timeout);
return new Response("LLM API error", { status: 502 });
}
// If the LLM API returns SSE (e.g., OpenAI with stream:true),
// strip SSE framing and extract text tokens before JSON parsing.
// For APIs that return raw NDJSON, remove the createSSEStripper() and
// token-extraction stages.
const textStream = llmResponse.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(createSSEStripper())
.pipeThrough(
new TransformStream<string, string>({
transform(ssePayload, controller) {
try {
const obj = JSON.parse(ssePayload);
const token = obj?.choices?.[0]?.delta?.content;
if (typeof token === "string") {
controller.enqueue(token);
}
} catch {
// Non-JSON SSE line; skip
}
},
})
);
const uiStream = textStream.pipeThrough(createJsonStreamParser(uiNodeSchema));
// Encode parsed UINode objects back to JSON lines for the client
const outputStream = uiStream.pipeThrough(
new TransformStream({
transform(node, controller) {
controller.enqueue(new TextEncoder().encode(JSON.stringify(node) + "
"));
},
flush() {
clearTimeout(timeout);
},
})
);
return new Response(outputStream, {
headers: {
"Content-Type": "application/x-ndjson",
"Transfer-Encoding": "chunked",
"Cache-Control": "no-store, no-cache",
},
});
}
Simplified alternative: If your LLM API returns raw NDJSON (not SSE), the route handler is much simpler. Pipe the response body directly through
createStreamPipelineand re-serialize the validated objects for the client. The SSE-stripping and token-reassembly stages above are only needed for OpenAI-style SSE streams.
Environment variables: Create a
.env.localfile withLLM_API_URL,LLM_API_KEY, and optionallyLLM_MODEL. Never commit API keys to source control. Next.js automatically loads.env.localon the server side; these variables are not exposed to the browser.
app/dashboard/generative/page.tsx: The Streaming UI View
// app/dashboard/generative/page.tsx
"use client";
import { useState, useTransition, useRef, useEffect } from "react";
import { renderNodeTree } from "@/components/generative/registry";
import type { UINode } from "@/lib/ui-node-schema";
const MAX_NODES = 200;
export default function GenerativePage() {
const [nodes, setNodes] = useState<UINode[]>([]);
const [isPending, startTransition] = useTransition();
const [prompt, setPrompt] = useState("");
const [fetchError, setFetchError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
async function handleGenerate() {
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
setNodes([]);
setFetchError(null);
startTransition(async () => {
let response: Response;
try {
response = await fetch("/api/generative", {
method: "POST",
signal: ac.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
} catch {
setFetchError("Request failed or was cancelled.");
return;
}
if (!response.ok) {
setFetchError(`API error: ${response.status}`);
return;
}
if (!response.body) return;
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let lineBuf = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
lineBuf += value;
const lines = lineBuf.split("
");
lineBuf = lines.pop() ?? "";
// Collect all valid nodes from this chunk, then batch one setNodes call
const newNodes: UINode[] = [];
for (const line of lines) {
if (!line.trim()) continue;
try {
const node = JSON.parse(line);
if (node && typeof node === "object" && "type" in node) {
newNodes.push(node as UINode);
}
} catch {
// Skip unparseable lines
}
}
if (newNodes.length > 0) {
setNodes((prev) => {
const remaining = MAX_NODES - prev.length;
if (remaining <= 0) return prev;
return [...prev, ...newNodes.slice(0, remaining)];
});
}
}
} catch (err) {
if (!ac.signal.aborted) {
setFetchError("Stream read error.");
}
} finally {
reader.releaseLock();
}
});
}
return (
<div className="generative-container" style={{ contain: "layout" }}>
<div className="prompt-bar">
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe the dashboard you want..."
/>
<button onClick={handleGenerate} disabled={isPending}>
{isPending ? "Generating..." : "Generate"}
button>
div>
{fetchError && (
<div role="alert" className="error-banner">{fetchError}div>
)}
<div className="generative-output">
{isPending && nodes.length === 0 && (
<div className="skeleton-grid">
<div className="skeleton skeleton-card" />
<div className="skeleton skeleton-card" />
<div className="skeleton skeleton-chart" />
div>
)}
{renderNodeTree(nodes)}
div>
div>
);
}
The page is a client component that fetches the streaming route handler and reads the NDJSON response line by line, batching all parsed nodes from each chunk into a single setNodes call as they arrive. Components render progressively. The useTransition hook keeps the UI responsive during streaming by marking the state updates as non-blocking. A MAX_NODES guard prevents unbounded memory growth from a misbehaving LLM. An AbortController tied to the component lifecycle ensures the fetch and reader are cancelled if the user navigates away or triggers a new generation.
React 19 required: The
startTransition(async () => { ... })pattern (passing an async function tostartTransition) is a React 19 feature. In React 18,isPendingwould flip tofalseimmediately after the async call starts. If you are on React 18, manage loading state with a separateuseStateboolean instead.
Preventing Layout Shifts with CSS Containment
The contain: layout declaration on the generative container prevents rendered children from triggering reflow in the surrounding page layout. Match skeleton dimensions to the expected component types: for example, set .skeleton-card to height: 120px matching GenCard's rendered height, and .skeleton-chart to height: 300px matching GenChart's reserved area. These dimensions require manual CSS maintenance to stay in sync with each component's actual rendered size. The schema defines component types, not pixel dimensions.
Performance, Observability, and Production Hardening
Backpressure and Buffering Strategies
When tokens arrive faster than React commits (common with smaller, faster models), the pipeline buffers more nodes. The default highWaterMark of 1 on the TransformStream limits internal queue depth. Note that this does not provide backpressure to the upstream HTTP connection; it only bounds the in-process queue. True backpressure to the LLM's HTTP response stream requires careful management of the source ReadableStream read rate and is outside the scope of this article.
Error Boundaries and Fallback UI
Wrapping the generative output in a React Error Boundary prevents a single component rendering failure from destroying the entire UI. The error boundary catches rendering exceptions and displays a fallback, while the stream continues to emit and render subsequent components. Combined with the inline error UINode type (which handles validation failures), this creates two layers of error handling: schema-level and render-level.
Observability
Logging validated versus rejected UINode payloads provides direct feedback for prompt debugging. When a model consistently emits nodes that fail validation, the logged Zod error messages pinpoint exactly which fields are malformed. Time-to-first-component (measured from stream start to the first setNodes call) and full-tree-render (measured from stream start to stream close) are the two key metrics for production monitoring. Both can be captured with simple performance.now() instrumentation around the reader loop.
Owning Your Generative UI Stack
The architecture described here has three layers, each with a single job: the stream parser turns bytes into objects, Zod validation turns objects into trusted typed nodes, and the component registry turns nodes into rendered React components. No layer depends on a vendor-specific abstraction. Beyond Next.js 15 and React 19, Zod is the only added runtime dependency (plus a charting library for the chart component type).
You get no proprietary lock-in to any AI SDK, full type safety from LLM output to rendered props, and minimal layout shifts thanks to pre-sized skeletons and CSS containment.
Clone the demo repository, swap in your own LLM endpoint via the LLM_API_URL environment variable, and extend the component registry with domain-specific types. Add a Zod variant, build a React component, drop an entry into the registry map. The parser and renderer do not change.
Common Pitfalls:
- Missing leaf components: The build will fail if
gen-heading.tsx,gen-card.tsx,gen-chart.tsx,gen-action-button.tsx, andgen-error.tsxare not present. Create stub implementations or use the ones from the repository.@/path alias: Ensure yourtsconfig.jsonmaps@/*to your project root (e.g.,"paths": { "@/*": ["./*"] }).- OpenAI SSE framing: Raw OpenAI streaming responses include
data:prefixes that will break the JSON parser if not stripped first.

