← Back to Blog

Server-Driven UI in Flutter: Build Dynamic Screens with Widgets

Flutter's widget tree is practically designed for server-driven UI. A JSON payload maps to a widget tree, and the framework handles the rendering. Here's how to implement SDUI in Flutter — from basic rendering to production-grade architecture.

Why Flutter Is a Natural Fit for SDUI

Flutter's architecture has three properties that make SDUI implementation cleaner than on most platforms:

This isn't theoretical. Nubank serves 115 million users with a Flutter SDUI framework called Catalyst. Their architecture uses a tree-walk interpreter to render dynamic layouts and logic from JSON payloads — proving that Flutter SDUI works at massive scale.

Architecture Overview

A Flutter SDUI system has four layers:

Layer 1 — Server
Defines screen layouts as structured data (JSON or a typed DSL). Handles targeting, A/B testing, and versioning. Returns a screen definition for a given route + user context.
Layer 2 — Transport & Cache
Fetches screen definitions from the server. Caches responses for offline support and instant rendering. Handles versioning and invalidation.
Layer 3 — Component Registry
Maps component type strings (e.g., "Text", "Card", "Carousel") to Flutter widget builders. This is where your team registers custom components.
Layer 4 — Renderer
Walks the JSON tree, looks up each node in the registry, passes properties, and builds the widget tree. Handles unknowns gracefully (skip or fallback).

Building a Basic SDUI Renderer

Let's build a working SDUI renderer step by step. We'll start with the server payload format, then build the client.

Step 1: Define the Payload Format

// Server response for a home screen
{
  "type": "Screen",
  "props": { "title": "Home" },
  "children": [
    {
      "type": "Column",
      "props": { "crossAxisAlignment": "stretch" },
      "children": [
        {
          "type": "PromoBanner",
          "props": {
            "title": "Spring Sale",
            "subtitle": "Up to 40% off",
            "imageUrl": "https://cdn.example.com/spring.jpg",
            "action": { "type": "navigate", "route": "/sale" }
          }
        },
        {
          "type": "SectionHeader",
          "props": { "text": "Trending Now" }
        },
        {
          "type": "HorizontalList",
          "props": { "itemHeight": 200 },
          "children": [
            {
              "type": "ProductCard",
              "props": {
                "name": "Wireless Earbuds",
                "price": "$49.99",
                "imageUrl": "https://cdn.example.com/earbuds.jpg"
              }
            }
          ]
        }
      ]
    }
  ]
}

Step 2: Create the Component Model

// lib/sdui/models.dart
class SDUINode {
  final String type;
  final Map<String, dynamic> props;
  final List<SDUINode> children;

  SDUINode({
    required this.type,
    this.props = const {},
    this.children = const [],
  });

  factory SDUINode.fromJson(Map<String, dynamic> json) {
    return SDUINode(
      type: json['type'] as String,
      props: json['props'] as Map<String, dynamic>? ?? {},
      children: (json['children'] as List<dynamic>?)
          ?.map((c) => SDUINode.fromJson(c as Map<String, dynamic>))
          .toList() ?? [],
    );
  }
}

Step 3: Build the Component Registry

// lib/sdui/registry.dart
typedef WidgetBuilder = Widget Function(
  SDUINode node,
  Widget Function(SDUINode) renderChild,
);

class ComponentRegistry {
  final Map<String, WidgetBuilder> _builders = {};

  void register(String type, WidgetBuilder builder) {
    _builders[type] = builder;
  }

  WidgetBuilder? get(String type) => _builders[type];

  bool has(String type) => _builders.containsKey(type);
}

// Register built-in components
ComponentRegistry createRegistry() {
  final registry = ComponentRegistry();

  registry.register('Column', (node, renderChild) {
    return Column(
      crossAxisAlignment: _parseCrossAxis(
        node.props['crossAxisAlignment']
      ),
      children: node.children.map(renderChild).toList(),
    );
  });

  registry.register('Row', (node, renderChild) {
    return Row(
      mainAxisAlignment: _parseMainAxis(
        node.props['mainAxisAlignment']
      ),
      children: node.children.map(renderChild).toList(),
    );
  });

  registry.register('Text', (node, renderChild) {
    return Text(
      node.props['text'] ?? '',
      style: _parseTextStyle(node.props['style']),
    );
  });

  registry.register('Image', (node, renderChild) {
    return Image.network(
      node.props['url'] ?? '',
      fit: _parseBoxFit(node.props['fit']),
    );
  });

  // Add more components...
  return registry;
}

Step 4: Build the Renderer

// lib/sdui/renderer.dart
class SDUIRenderer extends StatelessWidget {
  final SDUINode node;
  final ComponentRegistry registry;

  const SDUIRenderer({
    required this.node,
    required this.registry,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return _render(node);
  }

  Widget _render(SDUINode node) {
    final builder = registry.get(node.type);

    if (builder == null) {
      // Unknown component: skip or show placeholder
      return const SizedBox.shrink();
    }

    return builder(node, _render);
  }
}

Step 5: Wire It Up

// lib/screens/dynamic_screen.dart
class DynamicScreen extends StatefulWidget {
  final String route;
  const DynamicScreen({required this.route, super.key});

  @override
  State<DynamicScreen> createState() => _DynamicScreenState();
}

class _DynamicScreenState extends State<DynamicScreen> {
  late final ComponentRegistry _registry;
  SDUINode? _screen;
  bool _loading = true;

  @override
  void initState() {
    super.initState();
    _registry = createRegistry();
    _registerCustomComponents();
    _loadScreen();
  }

  void _registerCustomComponents() {
    // Register your app's custom components
    _registry.register('ProductCard', (node, renderChild) {
      return ProductCard(
        name: node.props['name'] ?? '',
        price: node.props['price'] ?? '',
        imageUrl: node.props['imageUrl'] ?? '',
        onTap: () => _handleAction(node.props['action']),
      );
    });

    _registry.register('PromoBanner', (node, renderChild) {
      return PromoBanner(
        title: node.props['title'] ?? '',
        subtitle: node.props['subtitle'] ?? '',
        imageUrl: node.props['imageUrl'] ?? '',
        onTap: () => _handleAction(node.props['action']),
      );
    });
  }

  Future<void> _loadScreen() async {
    // Fetch from server (with caching)
    final json = await SDUIClient.getScreen(widget.route);
    setState(() {
      _screen = SDUINode.fromJson(json);
      _loading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    if (_loading) return const LoadingIndicator();
    if (_screen == null) return const ErrorFallback();

    return SDUIRenderer(
      node: _screen!,
      registry: _registry,
    );
  }
}
💡 That's a Working SDUI Renderer
This basic implementation handles screen fetching, component registration, and recursive rendering. Production systems add caching, error boundaries, action handling, state management, and analytics — but the core pattern stays the same.

Production Considerations

Caching Strategy

Never make users wait for a network request on every screen load. Cache screen definitions aggressively:

Action System

UI without interaction is a poster. Your SDUI system needs an action layer that handles:

// Action types your renderer should support
{
  "type": "navigate",    // Push a route
  "route": "/product/123"
}

{
  "type": "openUrl",     // External browser
  "url": "https://example.com"
}

{
  "type": "httpRequest",  // API call
  "method": "POST",
  "url": "/api/cart/add",
  "body": { "productId": "123" }
}

{
  "type": "showDialog",   // Modal
  "title": "Confirm",
  "message": "Add to cart?"
}

{
  "type": "sequential",   // Chain actions
  "actions": [
    { "type": "httpRequest", ... },
    { "type": "showDialog", ... }
  ]
}

State Management

Some server-driven screens need local state — form inputs, toggles, counters. Two approaches:

Error Boundaries

A single broken component shouldn't crash the whole screen. Wrap each component render in an error boundary:

Widget _render(SDUINode node) {
  final builder = registry.get(node.type);

  if (builder == null) {
    return const SizedBox.shrink(); // Skip unknown
  }

  try {
    return builder(node, _render);
  } catch (e) {
    // Log the error, render fallback
    _analytics.logRenderError(node.type, e);
    return ErrorPlaceholder(type: node.type);
  }
}

For a deep dive on resilience patterns, see SDUI error handling and fallbacks.

SDUI vs Shorebird vs Code Push

Flutter developers have multiple options for updating apps without releases. Here's how they compare:

Capability SDUI Shorebird
Change UI layouts ✓ Any registered component ✓ Any Dart code
Add new logic ✗ Limited to actions ✓ Full Dart code
App store compliance ✓ Always safe (data, not code) ⚠️ Grey area on iOS
Rollback speed ✓ Instant (change server response) ✓ Fast (push new patch)
Scope of changes UI layout and content Anything in Dart
A/B testing built-in ✓ Per-user screen variants ✗ Separate system needed
Personalization ✓ Different screens per segment ✗ Same code for everyone
Complexity Medium (renderer + registry) Low (CLI + SDK)

The takeaway: Shorebird gives you broader update capabilities. SDUI gives you layout control, experimentation, and personalization. Many teams use both — Shorebird for bug fixes and logic changes, SDUI for screen-level layout control and A/B testing.

Registering Custom Components (BYOC)

The power of SDUI is that you use your widgets. Your design system, your brand, your interaction patterns. The registry just maps server types to your existing widgets:

// Register your design system components
registry.register('DSButton', (node, renderChild) {
  return MyDesignSystem.Button(
    label: node.props['label'] ?? '',
    variant: ButtonVariant.from(node.props['variant'] ?? 'primary'),
    size: ButtonSize.from(node.props['size'] ?? 'medium'),
    onPressed: () => handleAction(node.props['action']),
  );
});

registry.register('DSAvatar', (node, renderChild) {
  return MyDesignSystem.Avatar(
    imageUrl: node.props['imageUrl'],
    name: node.props['name'] ?? '',
    size: double.tryParse('${node.props['size']}') ?? 40,
  );
});

// Register a complex component with children
registry.register('DSAccordion', (node, renderChild) {
  return MyDesignSystem.Accordion(
    title: node.props['title'] ?? '',
    initiallyExpanded: node.props['expanded'] == true,
    child: Column(
      children: node.children.map(renderChild).toList(),
    ),
  );
});
🎯 BYOC (Bring Your Own Components)
This is the approach Pyramid takes. Instead of providing generic components, you register your design system widgets. The server composes screens from your components. The result looks and feels exactly like your app — because it is your app. Read more about the BYOC architecture pattern.

Flutter's 2026 Roadmap and SDUI

Flutter's 2026 roadmap includes two features that intersect with SDUI:

Neither feature replaces SDUI. GenUI is about dev-time productivity; bytecode delivery is about code updates. SDUI is about runtime layout control and experimentation — a different axis entirely.

Getting Started

If you want to add SDUI to your Flutter app, here's the recommended path:

  1. Pick one screen — Choose a high-churn screen like home, discovery, or onboarding
  2. Inventory your widgets — List the 10-15 widgets that screen uses today
  3. Build or adopt a renderer — Use the pattern above or integrate an SDUI framework
  4. Register your widgets — Map your existing design system components to the registry
  5. Define the screen server-side — Recreate your current layout as JSON
  6. Add caching and fallbacks — Stale-while-revalidate + cached default screen
  7. Ship behind a feature flag — Roll out to 5% and validate performance and correctness

For a broader migration strategy, see our SDUI adoption playbook and migration guide.

Related Articles

SDUI for Flutter, Without Building From Scratch

Pyramid provides the renderer, registry, action system, and server infrastructure — so you can focus on registering your widgets and defining screens. Native Flutter performance with server-side layout control.

Join the Waitlist →