Streaming
Progressive UI rendering powered by incremental JSON parsing. Users see components building in real time as LLM tokens arrive, instead of staring at a loading spinner until the full response is ready.
Overview
Traditional LLM integrations wait for the entire JSON response before rendering anything. With GenUIKit streaming, every token the model produces is fed into an incremental parser that heals partial JSON on the fly. As soon as enough props are available, the matching component starts rendering and progressively fills in as more data arrives.
The streaming pipeline has three layers that work together:
- --StreamParser— Low-level incremental JSON parser that heals incomplete tokens into valid objects.
- --useStreamingUI— React hook that connects a streaming source to your component registry and manages the full render lifecycle.
- --StreamResolver— Orchestration layer that ties parsing, throttling, abbreviation expansion, and error recovery into one pipeline.
| 1 | import { useStreamingUI } from '@genuikit/react'; |
| 2 | import { registry } from './registry'; |
| 3 | |
| 4 | function StreamingChat({ responseStream }) { |
| 5 | const { element, phase } = useStreamingUI(registry, responseStream); |
| 6 | |
| 7 | return ( |
| 8 | <div className="message"> |
| 9 | {phase === 'pending' && <p>Waiting for response...</p>} |
| 10 | {element} |
| 11 | {phase === 'complete' && <span className="checkmark">Done</span>} |
| 12 | </div> |
| 13 | ); |
| 14 | } |
StreamParser
The StreamParser from @genuikit/core is the foundation of the streaming system. It accepts raw string chunks (individual tokens from the LLM) and maintains a running parse of the incomplete JSON, healing it into a valid object at every step.
Core API
| Method / Property | Type | Description |
|---|---|---|
| push(chunk) | (chunk: string) => void | Feed a new token or chunk of text into the parser. |
| current | Record<string, unknown> | null | The current healed JSON object. Returns null until the first opening brace is seen. |
| complete | boolean | True when the parser has received a fully closed JSON object. |
How JSON Healing Works
When the parser receives a partial token stream, it closes any open strings, arrays, and objects to produce valid JSON at every step. For example, if the LLM has emitted {"city": "Tok, the parser heals it to {"city": "Tok"} so your component can already render with the partial value.
Token-by-Token Example
Here is what happens as tokens arrive one by one:
| 1 | import { StreamParser } from '@genuikit/core'; |
| 2 | |
| 3 | const parser = new StreamParser(); |
| 4 | |
| 5 | // Token 1: opening brace |
| 6 | parser.push('{"'); |
| 7 | console.log(parser.current); // {} |
| 8 | console.log(parser.complete); // false |
| 9 | |
| 10 | // Token 2: start of the "type" key |
| 11 | parser.push('type": "Weather'); |
| 12 | console.log(parser.current); // { type: "Weather" } |
| 13 | |
| 14 | // Token 3: close type value, start props |
| 15 | parser.push('Card", "props": {"city": "Tok'); |
| 16 | console.log(parser.current); |
| 17 | // { type: "WeatherCard", props: { city: "Tok" } } |
| 18 | |
| 19 | // Token 4: finish city, add temperature |
| 20 | parser.push('yo", "temperature": 22'); |
| 21 | console.log(parser.current); |
| 22 | // { type: "WeatherCard", props: { city: "Tokyo", temperature: 22 } } |
| 23 | |
| 24 | // Token 5: close everything |
| 25 | parser.push(', "condition": "sunny"}}'); |
| 26 | console.log(parser.current); |
| 27 | // { type: "WeatherCard", props: { city: "Tokyo", temperature: 22, condition: "sunny" } } |
| 28 | console.log(parser.complete); // true |
Notice that current always returns a valid object, even mid-token. The parser tracks nesting depth, string escape sequences, and number boundaries so it always knows exactly where to insert closing delimiters.
useStreamingUI Hook
The useStreamingUI hook from @genuikit/react is the primary way to connect a streaming LLM response to your component registry. It manages the full lifecycle from pending through skeleton, partial rendering, and completion.
Signature
| 1 | import { ComponentRegistry } from '@genuikit/core'; |
| 2 | |
| 3 | interface StreamingUIOptions { |
| 4 | // Shown while waiting for the first meaningful token |
| 5 | skeleton?: React.ReactNode; |
| 6 | |
| 7 | // Rendered if the stream fails or the component cannot be resolved |
| 8 | fallback?: React.ReactNode; |
| 9 | |
| 10 | // Called when the stream completes successfully |
| 11 | onComplete?: (result: { type: string; props: Record<string, unknown> }) => void; |
| 12 | |
| 13 | // Number of automatic retries on stream errors (default: 0) |
| 14 | retry?: number; |
| 15 | } |
| 16 | |
| 17 | interface StreamingUIResult { |
| 18 | // The current rendered element (updates progressively) |
| 19 | element: React.ReactNode | null; |
| 20 | |
| 21 | // Current phase of the stream lifecycle |
| 22 | phase: 'pending' | 'skeleton' | 'partial' | 'complete' | 'error'; |
| 23 | |
| 24 | // Validation errors if the final props fail schema checks |
| 25 | errors: string[] | null; |
| 26 | |
| 27 | // The raw parsed data at any point in the stream |
| 28 | data: Record<string, unknown> | null; |
| 29 | } |
| 30 | |
| 31 | function useStreamingUI( |
| 32 | registry: ComponentRegistry, |
| 33 | source: ReadableStream<string> | Response | null, |
| 34 | options?: StreamingUIOptions |
| 35 | ): StreamingUIResult; |
State Transitions
The hook moves through four phases as the stream progresses. Each phase maps to a distinct rendering state:
- pending — Source is null or has not emitted any tokens yet.
- skeleton — First bytes arrived, but not enough data to identify the component. Shows the skeleton placeholder.
- partial — Component type identified and rendering with partial props. Updates on every token.
- complete — Stream finished. Final props are validated against the schema.
Connecting to fetch()
The hook accepts a Response object directly, so you can pass the result of fetch() without any extra plumbing:
| 1 | 'use client'; |
| 2 | |
| 3 | import { useState } from 'react'; |
| 4 | import { useStreamingUI } from '@genuikit/react'; |
| 5 | import { registry } from '@/lib/registry'; |
| 6 | |
| 7 | export function StreamingChat() { |
| 8 | const [response, setResponse] = useState<Response | null>(null); |
| 9 | |
| 10 | const { element, phase, errors } = useStreamingUI(registry, response, { |
| 11 | skeleton: <div className="animate-pulse h-32 bg-gray-200 rounded-lg" />, |
| 12 | fallback: <div className="text-red-500">Failed to render component</div>, |
| 13 | onComplete: (result) => { |
| 14 | console.log('Stream complete:', result.type); |
| 15 | }, |
| 16 | retry: 2, |
| 17 | }); |
| 18 | |
| 19 | async function handleSend(message: string) { |
| 20 | const res = await fetch('/api/chat', { |
| 21 | method: 'POST', |
| 22 | headers: { 'Content-Type': 'application/json' }, |
| 23 | body: JSON.stringify({ message }), |
| 24 | }); |
| 25 | // Pass the Response directly - the hook reads the stream |
| 26 | setResponse(res); |
| 27 | } |
| 28 | |
| 29 | return ( |
| 30 | <div> |
| 31 | <button onClick={() => handleSend('Show me the weather in Tokyo')}> |
| 32 | Ask |
| 33 | </button> |
| 34 | |
| 35 | <div className="mt-4"> |
| 36 | {phase === 'pending' && <p className="text-gray-400">Send a message to start...</p>} |
| 37 | {element} |
| 38 | {phase === 'complete' && ( |
| 39 | <p className="text-green-500 text-sm mt-2">Rendering complete</p> |
| 40 | )} |
| 41 | {errors && ( |
| 42 | <p className="text-red-400 text-sm mt-2"> |
| 43 | Validation errors: {errors.join(', ')} |
| 44 | </p> |
| 45 | )} |
| 46 | </div> |
| 47 | </div> |
| 48 | ); |
| 49 | } |
Wire Format
LLM output tokens are expensive. The wire format module in @genuikit/core provides a compact encoding that abbreviates common keys and component names, reducing token count by roughly 30% without any loss of information.
API
| Function | Description |
|---|---|
| encode(data, map) | Compress a component payload using an abbreviation map. |
| decode(data, map) | Expand an abbreviated payload back to its full form. |
| generateWireFormatPrompt(registry) | Generate the abbreviation map and system prompt instructions for the LLM. |
| estimateSavings(data, map) | Returns the percentage of tokens saved by the compact encoding. |
Before & After
Here is a side-by-side comparison showing the token savings:
| 1 | import { encode, decode, estimateSavings } from '@genuikit/core/wire-format'; |
| 2 | |
| 3 | // Define your abbreviation map |
| 4 | const abbreviations = { |
| 5 | WeatherCard: 'WC', |
| 6 | temperature: 'tmp', |
| 7 | condition: 'cnd', |
| 8 | humidity: 'hmd', |
| 9 | city: 'c', |
| 10 | props: 'p', |
| 11 | type: 't', |
| 12 | }; |
| 13 | |
| 14 | // Full format (what the LLM would normally produce) |
| 15 | const full = { |
| 16 | type: 'WeatherCard', |
| 17 | props: { |
| 18 | city: 'Tokyo', |
| 19 | temperature: 22, |
| 20 | condition: 'sunny', |
| 21 | humidity: 65, |
| 22 | }, |
| 23 | }; |
| 24 | // JSON: {"type":"WeatherCard","props":{"city":"Tokyo","temperature":22,"condition":"sunny","humidity":65}} |
| 25 | // ~95 characters |
| 26 | |
| 27 | // Compact wire format |
| 28 | const compact = encode(full, abbreviations); |
| 29 | // { t: "WC", p: { c: "Tokyo", tmp: 22, cnd: "sunny", hmd: 65 } } |
| 30 | // JSON: {"t":"WC","p":{"c":"Tokyo","tmp":22,"cnd":"sunny","hmd":65}} |
| 31 | // ~63 characters |
| 32 | |
| 33 | console.log(estimateSavings(full, abbreviations)); |
| 34 | // { original: 95, compressed: 63, savedPercent: 33.7 } |
| 35 | |
| 36 | // Decode back to full form on the client |
| 37 | const restored = decode(compact, abbreviations); |
| 38 | // { type: "WeatherCard", props: { city: "Tokyo", temperature: 22, condition: "sunny", humidity: 65 } } |
Including Abbreviations in the System Prompt
Use generateWireFormatPrompt() to create an instruction block that tells the LLM to use the compact encoding. Include this in your system prompt alongside the tool definitions:
| 1 | import { ComponentRegistry } from '@genuikit/core'; |
| 2 | import { generateWireFormatPrompt } from '@genuikit/core/wire-format'; |
| 3 | import { registry } from './registry'; |
| 4 | |
| 5 | // Generate the abbreviation map and LLM instructions |
| 6 | const wireFormatBlock = generateWireFormatPrompt(registry); |
| 7 | // Returns a string like: |
| 8 | // "Use these abbreviations when outputting component JSON: |
| 9 | // WeatherCard -> WC, temperature -> tmp, condition -> cnd, ... |
| 10 | // Example: {"t":"WC","p":{"c":"Tokyo","tmp":22}} instead of |
| 11 | // {"type":"WeatherCard","props":{"city":"Tokyo","temperature":22}}" |
| 12 | |
| 13 | const systemPrompt = ` |
| 14 | You are a UI assistant. Respond with component JSON. |
| 15 | |
| 16 | ${registry.toToolDefinition()} |
| 17 | |
| 18 | ${wireFormatBlock} |
| 19 | `; |
StreamResolver
The StreamResolver orchestrates the full streaming pipeline: reading from a source, parsing tokens, throttling updates, expanding abbreviations, and emitting events. It is the engine that powers useStreamingUI under the hood, and you can use it directly for non-React contexts or advanced customization.
Pipeline
- source — Reads chunks from a ReadableStream, SSE connection, or WebSocket.
- parse — Feeds chunks into StreamParser, heals incomplete JSON.
- throttle — Batches rapid updates to limit re-renders (configurable interval).
- resolve — Expands abbreviations and resolves the component from the registry.
- emit — Fires snapshot, complete, or error events to listeners.
Configuration
| 1 | import { StreamResolver } from '@genuikit/core'; |
| 2 | import { registry } from './registry'; |
| 3 | |
| 4 | const resolver = new StreamResolver(registry, { |
| 5 | // Minimum interval between snapshot events (ms). |
| 6 | // Lower = smoother updates, higher = fewer re-renders. |
| 7 | throttleMs: 50, |
| 8 | |
| 9 | // Wire format abbreviation map for decoding compact payloads. |
| 10 | abbreviations: { |
| 11 | WeatherCard: 'WC', |
| 12 | temperature: 'tmp', |
| 13 | condition: 'cnd', |
| 14 | humidity: 'hmd', |
| 15 | city: 'c', |
| 16 | props: 'p', |
| 17 | type: 't', |
| 18 | }, |
| 19 | |
| 20 | // Retry configuration with exponential backoff. |
| 21 | retry: { |
| 22 | maxAttempts: 3, |
| 23 | baseDelayMs: 1000, // First retry after 1s |
| 24 | maxDelayMs: 10000, // Cap at 10s |
| 25 | backoffFactor: 2, // 1s -> 2s -> 4s |
| 26 | }, |
| 27 | }); |
Event Types
Subscribe to events to react to stream progress:
| 1 | import { StreamResolver } from '@genuikit/core'; |
| 2 | import { registry } from './registry'; |
| 3 | |
| 4 | const resolver = new StreamResolver(registry, { throttleMs: 50 }); |
| 5 | |
| 6 | // Fired on each throttled update with the current partial data |
| 7 | resolver.on('snapshot', (event) => { |
| 8 | console.log('Partial data:', event.data); |
| 9 | console.log('Component type:', event.componentType); // null until identified |
| 10 | console.log('Progress:', event.bytesReceived); |
| 11 | }); |
| 12 | |
| 13 | // Fired once when the stream ends and the final payload is valid |
| 14 | resolver.on('complete', (event) => { |
| 15 | console.log('Final component:', event.componentType); |
| 16 | console.log('Final props:', event.props); |
| 17 | console.log('Duration:', event.durationMs, 'ms'); |
| 18 | }); |
| 19 | |
| 20 | // Fired if the stream errors or the final payload fails validation |
| 21 | resolver.on('error', (event) => { |
| 22 | console.error('Stream error:', event.error); |
| 23 | console.log('Attempt:', event.attempt, 'of', event.maxAttempts); |
| 24 | console.log('Will retry:', event.willRetry); |
| 25 | }); |
| 26 | |
| 27 | // Start processing a stream |
| 28 | const response = await fetch('/api/chat', { |
| 29 | method: 'POST', |
| 30 | body: JSON.stringify({ message: 'Weather in Tokyo' }), |
| 31 | }); |
| 32 | |
| 33 | resolver.start(response.body!); |
Real-World Example
This end-to-end example shows a Next.js API route that streams component JSON from an LLM, paired with a client component that progressively renders the result. Both the server and client code are included.
Server: API Route
The API route calls the LLM with the component tool definitions and streams the response back to the client as a text stream:
| 1 | import { NextRequest } from 'next/server'; |
| 2 | import { registry } from '@/lib/registry'; |
| 3 | import { generateWireFormatPrompt } from '@genuikit/core/wire-format'; |
| 4 | |
| 5 | export async function POST(req: NextRequest) { |
| 6 | const { message } = await req.json(); |
| 7 | |
| 8 | const wireFormatBlock = generateWireFormatPrompt(registry); |
| 9 | |
| 10 | const llmResponse = await fetch('https://api.openai.com/v1/chat/completions', { |
| 11 | method: 'POST', |
| 12 | headers: { |
| 13 | 'Content-Type': 'application/json', |
| 14 | Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, |
| 15 | }, |
| 16 | body: JSON.stringify({ |
| 17 | model: 'gpt-4o', |
| 18 | stream: true, |
| 19 | messages: [ |
| 20 | { |
| 21 | role: 'system', |
| 22 | content: `You are a UI assistant. Respond ONLY with component JSON. |
| 23 | Available components: |
| 24 | ${registry.toToolDefinition()} |
| 25 | |
| 26 | ${wireFormatBlock}`, |
| 27 | }, |
| 28 | { role: 'user', content: message }, |
| 29 | ], |
| 30 | }), |
| 31 | }); |
| 32 | |
| 33 | // Transform the SSE stream into a plain text stream of JSON tokens |
| 34 | const encoder = new TextEncoder(); |
| 35 | const decoder = new TextDecoder(); |
| 36 | |
| 37 | const stream = new ReadableStream({ |
| 38 | async start(controller) { |
| 39 | const reader = llmResponse.body!.getReader(); |
| 40 | |
| 41 | try { |
| 42 | while (true) { |
| 43 | const { done, value } = await reader.read(); |
| 44 | if (done) break; |
| 45 | |
| 46 | const text = decoder.decode(value, { stream: true }); |
| 47 | const lines = text.split('\n').filter((line) => line.startsWith('data: ')); |
| 48 | |
| 49 | for (const line of lines) { |
| 50 | const data = line.slice(6); |
| 51 | if (data === '[DONE]') continue; |
| 52 | |
| 53 | try { |
| 54 | const parsed = JSON.parse(data); |
| 55 | const token = parsed.choices?.[0]?.delta?.content; |
| 56 | if (token) { |
| 57 | controller.enqueue(encoder.encode(token)); |
| 58 | } |
| 59 | } catch { |
| 60 | // Skip malformed SSE chunks |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | } finally { |
| 65 | controller.close(); |
| 66 | } |
| 67 | }, |
| 68 | }); |
| 69 | |
| 70 | return new Response(stream, { |
| 71 | headers: { |
| 72 | 'Content-Type': 'text/plain; charset=utf-8', |
| 73 | 'Transfer-Encoding': 'chunked', |
| 74 | 'Cache-Control': 'no-cache', |
| 75 | }, |
| 76 | }); |
| 77 | } |
Client: Streaming Component
The client component sends a message, passes the fetch response directly to useStreamingUI, and renders progressively:
| 1 | 'use client'; |
| 2 | |
| 3 | import { useState, useCallback } from 'react'; |
| 4 | import { useStreamingUI } from '@genuikit/react'; |
| 5 | import { registry } from '@/lib/registry'; |
| 6 | |
| 7 | export function ChatPanel() { |
| 8 | const [input, setInput] = useState(''); |
| 9 | const [response, setResponse] = useState<Response | null>(null); |
| 10 | const [history, setHistory] = useState<React.ReactNode[]>([]); |
| 11 | |
| 12 | const { element, phase, errors } = useStreamingUI(registry, response, { |
| 13 | skeleton: ( |
| 14 | <div className="animate-pulse space-y-3 p-4"> |
| 15 | <div className="h-4 bg-gray-200 rounded w-3/4" /> |
| 16 | <div className="h-4 bg-gray-200 rounded w-1/2" /> |
| 17 | <div className="h-20 bg-gray-200 rounded" /> |
| 18 | </div> |
| 19 | ), |
| 20 | fallback: ( |
| 21 | <div className="border border-red-300 bg-red-50 rounded-lg p-4"> |
| 22 | <p className="text-red-600 font-medium">Failed to render component</p> |
| 23 | {errors && <p className="text-red-400 text-sm mt-1">{errors.join(', ')}</p>} |
| 24 | </div> |
| 25 | ), |
| 26 | onComplete: (result) => { |
| 27 | // Archive the completed component into history |
| 28 | setHistory((prev) => [...prev, element]); |
| 29 | setResponse(null); |
| 30 | }, |
| 31 | retry: 2, |
| 32 | }); |
| 33 | |
| 34 | const handleSubmit = useCallback(async () => { |
| 35 | if (!input.trim()) return; |
| 36 | |
| 37 | const res = await fetch('/api/chat', { |
| 38 | method: 'POST', |
| 39 | headers: { 'Content-Type': 'application/json' }, |
| 40 | body: JSON.stringify({ message: input }), |
| 41 | }); |
| 42 | |
| 43 | setInput(''); |
| 44 | setResponse(res); |
| 45 | }, [input]); |
| 46 | |
| 47 | return ( |
| 48 | <div className="flex flex-col h-full"> |
| 49 | {/* Message history */} |
| 50 | <div className="flex-1 overflow-y-auto p-4 space-y-4"> |
| 51 | {history.map((item, i) => ( |
| 52 | <div key={i} className="border rounded-lg p-4"> |
| 53 | {item} |
| 54 | </div> |
| 55 | ))} |
| 56 | |
| 57 | {/* Current streaming element */} |
| 58 | {phase !== 'pending' && element && ( |
| 59 | <div className="border rounded-lg p-4 border-blue-200"> |
| 60 | <div className="flex items-center gap-2 mb-2"> |
| 61 | <span className="text-xs text-blue-500 font-medium uppercase"> |
| 62 | {phase} |
| 63 | </span> |
| 64 | {phase === 'partial' && ( |
| 65 | <span className="inline-block w-2 h-2 bg-blue-400 rounded-full animate-pulse" /> |
| 66 | )} |
| 67 | </div> |
| 68 | {element} |
| 69 | </div> |
| 70 | )} |
| 71 | </div> |
| 72 | |
| 73 | {/* Input */} |
| 74 | <div className="border-t p-4 flex gap-2"> |
| 75 | <input |
| 76 | type="text" |
| 77 | value={input} |
| 78 | onChange={(e) => setInput(e.target.value)} |
| 79 | onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} |
| 80 | placeholder="Ask for a UI component..." |
| 81 | className="flex-1 border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" |
| 82 | /> |
| 83 | <button |
| 84 | onClick={handleSubmit} |
| 85 | disabled={phase === 'partial' || phase === 'skeleton'} |
| 86 | className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50" |
| 87 | > |
| 88 | Send |
| 89 | </button> |
| 90 | </div> |
| 91 | </div> |
| 92 | ); |
| 93 | } |