Core Concepts
Understand the fundamental patterns that power GenUIKit: registries, validation, resolution, and the self-healing correction loop.
The Registry Pattern
At the heart of GenUIKit is the ComponentRegistry -- a lookup table that maps a component name to a Zod schema and a React component. When an LLM produces output like { type: "WeatherCard", props: { ... } }, the registry knows exactly which schema to validate against and which component to render.
How the registry works internally:
Create a registry and register components using the chainable register() API. Each call returns this, so you can chain multiple registrations together:
| 1 | import { z } from 'zod'; |
| 2 | import { ComponentRegistry } from '@genuikit/core'; |
| 3 | |
| 4 | // Define schemas |
| 5 | const weatherSchema = z.object({ |
| 6 | city: z.string(), |
| 7 | temperature: z.number(), |
| 8 | condition: z.enum(['sunny', 'cloudy', 'rainy', 'snowy']), |
| 9 | }); |
| 10 | |
| 11 | const alertSchema = z.object({ |
| 12 | title: z.string(), |
| 13 | message: z.string(), |
| 14 | severity: z.enum(['info', 'warning', 'error']), |
| 15 | }); |
| 16 | |
| 17 | // Create registry with chainable register() calls |
| 18 | const registry = new ComponentRegistry() |
| 19 | .register('WeatherCard', weatherSchema, WeatherCard) |
| 20 | .register('AlertBanner', alertSchema, AlertBanner); |
| 21 | |
| 22 | // You can also register components individually |
| 23 | registry.register('UserProfile', userProfileSchema, UserProfile); |
The registry stores entries in an internal Map<string, RegistryEntry>. Each entry holds the component name, its Zod schema, and the React component reference:
| 1 | // Each entry in the registry has this shape |
| 2 | interface RegistryEntry<S extends z.ZodType = z.ZodType> { |
| 3 | readonly name: string; |
| 4 | readonly schema: S; |
| 5 | readonly component: ComponentType<z.infer<S>>; |
| 6 | } |
By default, registering a component with a name that already exists throws a DuplicateRegistrationError. You can opt into overwrites by passing { allowOverwrite: true } to the constructor:
| 1 | // Allow replacing existing registrations |
| 2 | const registry = new ComponentRegistry({ allowOverwrite: true }); |
| 3 | registry.register('Button', schemaV1, ButtonV1); |
| 4 | registry.register('Button', schemaV2, ButtonV2); // replaces V1 |
LLM Component Output
GenUIKit expects a specific shape from the LLM: an object with type (the registered component name) and props (the data matching the Zod schema). This is the contract between your LLM and the registry.
| 1 | // The shape your LLM must produce |
| 2 | interface LLMComponentOutput { |
| 3 | readonly type: string; // registered component name |
| 4 | readonly props: Record<string, unknown>; // data matching the Zod schema |
| 5 | } |
When structuring LLM tool calls, instruct the model to return this exact shape. Here is an example of what the LLM should produce when asked about weather:
| 1 | { |
| 2 | "type": "WeatherCard", |
| 3 | "props": { |
| 4 | "city": "San Francisco", |
| 5 | "temperature": 18, |
| 6 | "condition": "cloudy" |
| 7 | } |
| 8 | } |
In your API route or server action, parse the LLM response and pass it directly to the registry for resolution:
| 1 | import { registry } from '@/lib/registry'; |
| 2 | |
| 3 | export async function POST(req: Request) { |
| 4 | const { message } = await req.json(); |
| 5 | |
| 6 | // Call your LLM (OpenAI, Anthropic, etc.) |
| 7 | const llmResponse = await callLLM(message, { |
| 8 | tools: registry.toToolDefinition(), |
| 9 | }); |
| 10 | |
| 11 | // The LLM returns { type: "WeatherCard", props: { ... } } |
| 12 | return Response.json(llmResponse); |
| 13 | } |
Resolving Output
The registry provides two methods for resolving LLM output into validated components: resolve() for strict resolution and tryResolve() for lenient resolution.
resolve() -- Strict Resolution
The resolve() method validates the LLM output against the registered schema. It returns a discriminated union: either a success result with the component and validated props, or a failure result with detailed errors and a correction prompt.
| 1 | const output = { type: 'WeatherCard', props: { city: 'Tokyo', temperature: 22, condition: 'sunny' } }; |
| 2 | const result = registry.resolve(output); |
| 3 | |
| 4 | if (result.ok) { |
| 5 | // Success: result has component + validated props |
| 6 | const { component: Component, props, name } = result; |
| 7 | // Render: <Component {...props} /> |
| 8 | } else { |
| 9 | // Failure: result has errors + correction prompt |
| 10 | const { errors, correctionPrompt, name } = result; |
| 11 | // Send correctionPrompt back to the LLM for retry |
| 12 | } |
On success, the result contains the resolved component and the parsed (and possibly transformed) props. On failure, you get structured validation errors and a correction prompt:
| 1 | // Success result shape |
| 2 | interface ResolveSuccess<P = unknown> { |
| 3 | readonly ok: true; |
| 4 | readonly name: string; |
| 5 | readonly component: ComponentType<P>; |
| 6 | readonly props: P; |
| 7 | } |
| 8 | |
| 9 | // Error result shape |
| 10 | interface ResolveError { |
| 11 | readonly ok: false; |
| 12 | readonly name: string; |
| 13 | readonly errors: ValidationError[]; |
| 14 | readonly correctionPrompt: string; |
| 15 | } |
Important: If the type does not match any registered component name, resolve() throws a ComponentNotFoundError rather than returning an error result. This is because an unknown component name cannot be corrected through validation alone.
tryResolve() -- Lenient Resolution
The tryResolve() method works the same as resolve(), but returns null instead of throwing when the component name is not found. This is useful when you want to silently skip unknown components:
| 1 | const result = registry.tryResolve(output); |
| 2 | |
| 3 | if (result === null) { |
| 4 | // Component not registered -- silently skip or show fallback |
| 5 | return <div>Unknown component</div>; |
| 6 | } |
| 7 | |
| 8 | if (result.ok) { |
| 9 | const { component: Component, props } = result; |
| 10 | return <Component {...props} />; |
| 11 | } else { |
| 12 | // Validation failed -- use correctionPrompt for retry |
| 13 | console.log(result.correctionPrompt); |
| 14 | } |
When to use each method:
- --Use
resolve()when you expect the component to exist and want to catch mismatches as errors (e.g., in a controlled chat UI). - --Use
tryResolve()when the LLM might produce arbitrary component names and you want graceful fallbacks (e.g., in a plugin system).
Auto-Correction Prompts
When schema validation fails, GenUIKit generates a structured correction prompt that describes exactly what went wrong. You can send this prompt back to the LLM so it can fix its output -- creating a self-healing loop.
Here is how the flow works. Suppose the LLM produces invalid props:
| 1 | // LLM returns invalid props (temperature is a string, not a number) |
| 2 | const invalidOutput = { |
| 3 | type: 'WeatherCard', |
| 4 | props: { |
| 5 | city: 'Berlin', |
| 6 | temperature: 'warm', // should be a number |
| 7 | condition: 'foggy', // not in the enum |
| 8 | }, |
| 9 | }; |
| 10 | |
| 11 | const result = registry.resolve(invalidOutput); |
| 12 | // result.ok === false |
The correctionPrompt in the error result contains a detailed message for the LLM:
| 1 | console.log(result.correctionPrompt); |
| 2 | // Output: |
| 3 | // The props you provided for component "WeatherCard" failed validation. |
| 4 | // |
| 5 | // Errors: |
| 6 | // - "temperature": Expected number, received string, expected: number, received: string |
| 7 | // - "condition": Invalid enum value. Expected 'sunny' | 'cloudy' | 'rainy' | 'snowy', |
| 8 | // received 'foggy' |
| 9 | // |
| 10 | // Expected schema: |
| 11 | // { |
| 12 | // city: string |
| 13 | // temperature: number |
| 14 | // condition: "sunny" | "cloudy" | "rainy" | "snowy" |
| 15 | // } |
| 16 | // |
| 17 | // Please provide corrected props for "WeatherCard" as a valid JSON object |
| 18 | // matching the schema above. |
Send the correction prompt back to the LLM and it will produce corrected output:
| 1 | async function resolveWithRetry( |
| 2 | registry: ComponentRegistry, |
| 3 | output: LLMComponentOutput, |
| 4 | maxRetries = 2 |
| 5 | ) { |
| 6 | let result = registry.resolve(output); |
| 7 | let attempts = 0; |
| 8 | |
| 9 | while (!result.ok && attempts < maxRetries) { |
| 10 | // Send correction prompt back to the LLM |
| 11 | const corrected = await callLLM(result.correctionPrompt); |
| 12 | result = registry.resolve(corrected); |
| 13 | attempts++; |
| 14 | } |
| 15 | |
| 16 | return result; |
| 17 | } |
The self-healing loop:
Tool Definitions
The toToolDefinition() method generates a JSON description of all registered components and their expected props. Send this to the LLM as part of your system prompt or tool definitions so it knows which components are available.
| 1 | import { ComponentRegistry } from '@genuikit/core'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | const registry = new ComponentRegistry() |
| 5 | .register('WeatherCard', z.object({ |
| 6 | city: z.string(), |
| 7 | temperature: z.number(), |
| 8 | condition: z.enum(['sunny', 'cloudy', 'rainy', 'snowy']), |
| 9 | humidity: z.number().min(0).max(100).optional(), |
| 10 | }), WeatherCard) |
| 11 | .register('AlertBanner', z.object({ |
| 12 | title: z.string(), |
| 13 | message: z.string(), |
| 14 | severity: z.enum(['info', 'warning', 'error']), |
| 15 | }), AlertBanner); |
| 16 | |
| 17 | const toolDefs = registry.toToolDefinition(); |
| 18 | console.log(JSON.stringify(toolDefs, null, 2)); |
The output is an array of objects, each with a name and a JSON Schema-like schema description:
| 1 | [ |
| 2 | { |
| 3 | "name": "WeatherCard", |
| 4 | "schema": { |
| 5 | "type": "object", |
| 6 | "properties": { |
| 7 | "city": { "type": "string" }, |
| 8 | "temperature": { "type": "number" }, |
| 9 | "condition": { "type": "string", "enum": ["sunny", "cloudy", "rainy", "snowy"] }, |
| 10 | "humidity": { "type": "number" } |
| 11 | }, |
| 12 | "required": ["city", "temperature", "condition"] |
| 13 | } |
| 14 | }, |
| 15 | { |
| 16 | "name": "AlertBanner", |
| 17 | "schema": { |
| 18 | "type": "object", |
| 19 | "properties": { |
| 20 | "title": { "type": "string" }, |
| 21 | "message": { "type": "string" }, |
| 22 | "severity": { "type": "string", "enum": ["info", "warning", "error"] } |
| 23 | }, |
| 24 | "required": ["title", "message", "severity"] |
| 25 | } |
| 26 | } |
| 27 | ] |
Include this in your LLM system prompt to teach the model what components it can produce:
| 1 | const systemPrompt = `You are a UI assistant. When the user asks a question, |
| 2 | respond with a JSON object matching one of these components: |
| 3 | |
| 4 | ${JSON.stringify(registry.toToolDefinition(), null, 2)} |
| 5 | |
| 6 | Respond with: { "type": "<ComponentName>", "props": { ... } }`; |
| 7 | |
| 8 | const response = await openai.chat.completions.create({ |
| 9 | model: 'gpt-4o', |
| 10 | messages: [ |
| 11 | { role: 'system', content: systemPrompt }, |
| 12 | { role: 'user', content: 'What is the weather in Tokyo?' }, |
| 13 | ], |
| 14 | }); |
Error Handling
GenUIKit provides two specific error classes for registry operations. Understanding when each is thrown helps you build robust error handling.
ComponentNotFoundError
Thrown by resolve() when the type field in the LLM output does not match any registered component name. This error is not recoverable through the correction loop because the component simply does not exist in the registry.
| 1 | import { ComponentNotFoundError } from '@genuikit/core'; |
| 2 | |
| 3 | try { |
| 4 | const result = registry.resolve({ |
| 5 | type: 'NonExistentWidget', |
| 6 | props: { title: 'Hello' }, |
| 7 | }); |
| 8 | } catch (error) { |
| 9 | if (error instanceof ComponentNotFoundError) { |
| 10 | console.error(error.message); |
| 11 | // "Component "NonExistentWidget" is not registered." |
| 12 | |
| 13 | // Option 1: Show fallback UI |
| 14 | return <div>Unknown component type</div>; |
| 15 | |
| 16 | // Option 2: Use tryResolve() instead to avoid the exception |
| 17 | } |
| 18 | } |
DuplicateRegistrationError
Thrown when you attempt to register a component with a name that is already in use. This is a setup-time error that you typically catch during development:
| 1 | import { DuplicateRegistrationError } from '@genuikit/core'; |
| 2 | |
| 3 | const registry = new ComponentRegistry(); |
| 4 | registry.register('Button', buttonSchema, Button); |
| 5 | |
| 6 | try { |
| 7 | registry.register('Button', altButtonSchema, AltButton); |
| 8 | } catch (error) { |
| 9 | if (error instanceof DuplicateRegistrationError) { |
| 10 | console.error(error.message); |
| 11 | // "Component "Button" is already registered. Use allowOverwrite option to replace." |
| 12 | } |
| 13 | } |
| 14 | |
| 15 | // To allow overwrites, pass the option to the constructor: |
| 16 | const flexibleRegistry = new ComponentRegistry({ allowOverwrite: true }); |
Comprehensive Error Handling
Here is a complete pattern that handles all error cases gracefully:
| 1 | import { ComponentNotFoundError } from '@genuikit/core'; |
| 2 | import type { LLMComponentOutput, ResolveResult } from '@genuikit/core'; |
| 3 | |
| 4 | function safeRender( |
| 5 | registry: ComponentRegistry, |
| 6 | output: LLMComponentOutput |
| 7 | ): { element: JSX.Element | null; correctionPrompt: string | null } { |
| 8 | // Handle unknown component names |
| 9 | const result = registry.tryResolve(output); |
| 10 | |
| 11 | if (result === null) { |
| 12 | // Component not in registry -- nothing to correct |
| 13 | return { |
| 14 | element: <div>Component "{output.type}" is not available.</div>, |
| 15 | correctionPrompt: null, |
| 16 | }; |
| 17 | } |
| 18 | |
| 19 | if (result.ok) { |
| 20 | // Validation passed -- render the component |
| 21 | const { component: Component, props } = result; |
| 22 | return { |
| 23 | element: <Component {...props} />, |
| 24 | correctionPrompt: null, |
| 25 | }; |
| 26 | } |
| 27 | |
| 28 | // Validation failed -- return correction prompt for LLM retry |
| 29 | return { |
| 30 | element: null, |
| 31 | correctionPrompt: result.correctionPrompt, |
| 32 | }; |
| 33 | } |
What's Next
Now that you understand the core patterns, explore more advanced topics: