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:
- Everything is a widget. Unlike Android (Views vs. Compose) or iOS (UIKit vs. SwiftUI), Flutter has one unified widget system. Your SDUI renderer only needs to target one rendering model.
- Declarative by default. Flutter widgets are already declared as a tree structure — which is exactly what a server JSON payload describes. The mapping is nearly one-to-one.
- Cross-platform. Build one SDUI renderer, and it works on Android, iOS, web, and desktop. One implementation covers every platform.
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:
"Text", "Card", "Carousel") to Flutter widget builders. This is where your team registers custom components.
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,
);
}
}
Production Considerations
Caching Strategy
Never make users wait for a network request on every screen load. Cache screen definitions aggressively:
- Memory cache — Keep recently used screens in memory for instant rendering
- Disk cache — Persist to SQLite or Hive for offline support
- Stale-while-revalidate — Show cached version immediately, fetch updates in the background
- Version headers — Server sends a version hash; client only downloads if changed
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:
- Server-managed state: Every interaction sends a request, server returns the updated screen. Simple but network-dependent. This is what Nubank's Catalyst uses.
- Client-managed state: The server defines state variables and binding expressions. The client evaluates them locally. More complex but snappier UX.
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(),
),
);
});
Flutter's 2026 Roadmap and SDUI
Flutter's 2026 roadmap includes two features that intersect with SDUI:
- GenUI SDK: AI-generated UI at development time. Complementary to SDUI — GenUI could generate component schemas that SDUI serves dynamically.
- Interpreted bytecode delivery: The team is exploring loading Dart code on demand without full app updates. If shipped, this would be a native Code Push alternative, but SDUI remains the safer, more controlled option for layout changes.
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:
- Pick one screen — Choose a high-churn screen like home, discovery, or onboarding
- Inventory your widgets — List the 10-15 widgets that screen uses today
- Build or adopt a renderer — Use the pattern above or integrate an SDUI framework
- Register your widgets — Map your existing design system components to the registry
- Define the screen server-side — Recreate your current layout as JSON
- Add caching and fallbacks — Stale-while-revalidate + cached default screen
- 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 →