DEV Community

Dhinesh K.S
Dhinesh K.S

Posted on

Building a New-Gen Chat Widget: JSON-Driven Rendering Architecture

Hero image


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
Enter fullscreen mode Exit fullscreen mode

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"
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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()
});
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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
]);
Enter fullscreen mode Exit fullscreen mode

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} />;
};
Enter fullscreen mode Exit fullscreen mode

Event Handling & Callbacks

User interactions (clicks, form submissions) are handled through action callbacks:

{
  "type": "button",
  "label": "Track Order",
  "action": "track_order_postback"
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Flow:

  1. User clicks button
  2. Widget captures click and extracts action
  3. Widget sends postMessage to server
  4. Server processes action and responds with new schema
  5. 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} />;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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

Rendering flow


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
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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)