Modern chat widgets are no longer static UI containers—they're becoming dynamic platforms that serve multiple product teams, message types, and user experiences. This evolution introduces a critical architectural question:
How do you build a widget flexible enough to evolve rapidly without turning every UI change into a deployment?
The answer lies in decoupling UI structure from application code. In this deep dive, we'll explore one powerful solution: JSON-driven rendering combined with a component interface design.
The Problem with Hardcoded UI
In traditional widget implementations, each message type maps directly to a React component in the codebase. Want a new card layout? Write a component. Want to reorder elements? Modify the code. Want to test a different button arrangement? Deploy.
This coupling between product decisions and engineering cycles becomes a significant bottleneck:
- Every UI variation requires code changes
- Pull requests and reviews slow iteration
- Full deployment cycles for small layout tweaks
- Product teams depend on engineering timelines
At scale, this is unsustainable. What we needed was a way for UI to evolve independently of the widget codebase.
The Solution: Description Over Prescription
Instead of hardcoding components, we inverted the model:
The Server describes what the widget should look like using a structured JSON schema.
The Widget interprets that description and renders the appropriate components.
Responsibilities are now cleanly separated:
Server: "Render a card with text and two buttons"
↓
Widget: "I'll parse that schema and render the exact components needed"
↓
Result: Dynamic UI that matches the schema
Example Schema
{
"type": "card",
"children": [
{
"type": "text",
"value": "How can I help you?"
},
{
"type": "row",
"gap": "8px",
"children": [
{
"type": "button",
"label": "Track Order",
"action": "postback"
},
{
"type": "button",
"label": "Talk to Agent",
"action": "handoff"
}
]
}
]
}
This represents a component tree, where layout is implicitly defined by nesting. The rendering engine receives this JSON, walks the tree, resolves each type to a registered UI component, and produces the final interface.
The widget itself has no knowledge of specific layouts—it only knows how to resolve and render components.
How the Rendering Engine Works
The rendering engine follows a strict pipeline: validate → resolve → render.
1. Validation Layer (Zod): Trust Before Render
Before anything touches the UI, incoming schemas are validated against expected types:
const ButtonSchema = z.object({
type: z.literal("button"),
label: z.string(),
action: z.string().optional()
});
const RowSchema = z.object({
type: z.literal("row"),
children: z.array(z.lazy(() => ComponentSchema)),
gap: z.string().optional()
});
This ensures:
- Missing fields are rejected before rendering
- Invalid types are caught immediately
- Unsupported structures never reach the widget
Only trusted, well-formed data enters the system.
2. Schema Registry: Defining the Contract
The Schema Registry acts as a formal contract between backend and frontend. Only known component types are allowed to render.
Each component type (card, text, button, row) must exist in this registry. Unknown types are rejected immediately, preventing silent failures.
3. Component Registry: Mapping Schema to UI
The Component Registry maps schema type strings to actual React components:
const componentRegistry = {
text: TextComponent,
button: ButtonComponent,
card: CardComponent,
row: RowComponent
};
This enables:
- Pluggable architecture
- Easy extensibility
- Clear separation of concerns
4. Component Interface Design: Props & Contracts
For the rendering engine to work reliably, every component must have a well-defined interface. This means clearly specifying what props each component accepts, how they're validated, and how they interact with the widget.
Props Validation with Zod
Each component type has a corresponding Zod schema that validates its props:
// Define what a button component expects
const ButtonPropsSchema = z.object({
type: z.literal("button"),
label: z.string().min(1),
action: z.string(),
disabled: z.boolean().optional(),
styles: ButtonStylesSchema.optional()
});
// Define what a text component expects
const TextPropsSchema = z.object({
type: z.literal("text"),
value: z.string(),
align: z.enum(["left", "center", "right"]).optional(),
styles: TextStylesSchema.optional()
});
// Define what a row component expects (generic layout container)
const RowPropsSchema = z.object({
type: z.literal("row"),
children: z.array(z.lazy(() => ComponentPropsSchema)),
gap: z.string().optional(),
styles: RowStylesSchema.optional()
});
// Combine all schemas
const ComponentPropsSchema = z.discriminatedUnion("type", [
ButtonPropsSchema,
TextPropsSchema,
CardPropsSchema,
RowPropsSchema
]);
This ensures:
- ✅ Required props are present
- ✅ Prop types are correct
- ✅ Unknown props are rejected
- ✅ Invalid data never reaches components
Mapping JSON to Component Props
Once validated, the schema node is mapped to actual React component props:
const ComponentRegistry = {
button: (props) => <Button {...props} />,
text: (props) => <Text {...props} />,
card: (props) => <Card {...props} />,
row: (props) => <Row {...props} />
};
const renderNode = (node) => {
// Validate props against schema
const validatedProps = ComponentPropsSchema.parse(node);
// Get component from registry
const Component = ComponentRegistry[validatedProps.type];
if (!Component) {
return <FallbackComponent type={validatedProps.type} />;
}
// Render with validated props
return <Component {...validatedProps} />;
};
Event Handling & Callbacks
User interactions (clicks, form submissions) are handled through action callbacks:
{
"type": "button",
"label": "Track Order",
"action": "track_order_postback"
}
The widget captures the interaction and sends it back to the server:
const handleButtonClick = (action: string) => {
// Send action to server via postMessage
window.parent.postMessage(
{
type: "user_action",
payload: { action }
},
"*"
);
};
// In the button component
<button onClick={() => handleButtonClick(props.action)}>
{props.label}
</button>
Flow:
- User clicks button
- Widget captures click and extracts
action - Widget sends
postMessageto server - Server processes action and responds with new schema
- Widget re-renders with updated content
Type Safety Across Boundaries
For type safety between server (backend) and client (widget), define shared types in a common package:
// packages/shared-schemas/component.ts
export const ButtonSchema = z.object({
type: z.literal("button"),
label: z.string(),
action: z.string(),
disabled: z.boolean().optional()
});
export type ButtonProps = z.infer<typeof ButtonSchema>;
// Backend uses it for response validation
const response: ButtonProps = {
type: "button",
label: "Continue",
action: "next_step"
};
// Frontend uses same type for rendering
const render = (props: ButtonProps) => <Button {...props} />;
This ensures type safety across the entire system — schema definition, validation, and rendering all use the same source of truth.
5. Parse → Resolve → Render
The engine recursively walks the validated schema structure:
1. Parse validated schema
2. Resolve component type from registry
3. Pass props to component
4. Recursively render children
card
├── text
└── row
├── button
└── button
Each level of the tree is independently resolved and rendered.
Design Tokens & Theming through JSON
Components in the registry can accept design tokens as props, allowing the server to control styling without shipping CSS changes.
This extends the rendering engine's flexibility to include visual properties:
{
"type": "button",
"label": "Track Order",
"action": "postback",
"styles": {
"backgroundColor": "primary",
"textColor": "white",
"variant": "solid"
}
}
Instead of hardcoding button colors, the server specifies a token. The component resolves it against a design token map:
const designTokens = {
primary: "#FFD700",
secondary: "#F5F5F5",
text: {
primary: "#000000",
secondary: "#666666"
},
spacing: {
sm: "8px",
md: "16px",
lg: "24px"
}
};
const resolveToken = (tokenPath: string) => {
return tokenPath.split('.').reduce((obj, key) => obj[key], designTokens);
};
Why This Matters
- Server-side theming — Brands can customize colors, spacing, typography without widget code changes
- Consistent design language — All components use the same token system
- A/B testing — Test different color schemes or spacing by changing tokens server-side
- Runtime customization — Different organizations can embed the same widget with different branding
Example with Tokens
{
"type": "card",
"styles": {
"padding": "md",
"backgroundColor": "surface"
},
"children": [
{
"type": "text",
"value": "How can I help you?",
"styles": {
"color": "text.primary",
"fontSize": "lg"
}
},
{
"type": "button",
"label": "Continue",
"styles": {
"backgroundColor": "primary",
"borderRadius": "md"
}
}
]
}
The rendering engine resolves each token reference at render time, producing a fully styled component tree that reflects both server-side intent and client-side design system.
Final Pipeline
Why This Architecture Scales
Flexible UI Composition
Product teams can ship new message types (carousels, date pickers, rich media) without coordinating a widget release. The schema evolves independently of the rendering logic.
Faster Product Iteration
Experiment with layout variations without frequent deployments. Changes ship from the server immediately.
Deterministic Rendering
The rendering engine behaves like a pure function:
JSON Input → Validated Schema → UI Output
Given the same JSON, it always produces the same UI. Easy to test, predict, and debug.
Clear System Contracts
The JSON schema becomes a formal interface between backend and widget. Both sides know exactly what to expect.
Type-Safe Development
Shared type definitions ensure correctness across the whole stack — from schema design to component props to rendering.
Graceful Degradation
Unknown component types can fail safely without breaking the entire widget:
const Component = componentRegistry.get(node.type);
if (!Component) {
return <FallbackComponent message={`Unknown type: ${node.type}`} />;
}
return <Component {...node.props}>{node.children}</Component>;
The Tradeoff: Schema Complexity
This architecture shifts complexity from UI code to schema design. You now need to think carefully about:
- Schema expressiveness — Can it describe all the UI variations you need?
- Versioning strategy — How do you evolve the schema without breaking existing clients?
- Backward compatibility — Can old schemas work with new rendering engines?
- Props contracts — How do you handle prop changes across component types?
However, for systems that require continuous evolution, the flexibility more than justifies this upfront investment. Schema design happens once; UI iteration happens continuously.
Real-World Impact
In practice, this architecture has allowed us to:
- Ship new message types without deploying the widget
- Test layout variations server-side before rolling out
- Support different message types across multiple products from a single widget
- Keep the rendering engine lightweight, predictable, and testable
- Enable type-safe development with shared schemas across backend and frontend
Key Insight
Rendering isn't the hard part—ensuring the input is valid, contracts are clear, and types are safe is what makes the system production-ready.
By combining strict validation, well-defined component interfaces, design tokens, and a pluggable registry, we built a system that's both flexible and safe.
Next in the Series
In the next post, we'll explore how cross-origin iframe isolation ensures the widget remains safe and predictable regardless of where it's embedded.
Stay tuned.
Questions? Drop a comment below or reach out on LinkedIn


Top comments (0)