+1 (415) 480-3939

Server-Driven UI in Flutter: Change Screens Without Shipping a Build

Every long-running Flutter engagement reaches the moment when a product manager asks for something that sounds trivial: move the promo banner above the fold, reorder the onboarding steps, add a field to the intake form — by Friday. The engineering answer is a code change, a release build, a store review, and a staged rollout that reaches maybe 70% of your installed base within a week. The gap between "trivial change" and "two-week cycle" is where server-driven UI (SDUI) earns its place.

SDUI means the server sends a description of the screen — a JSON tree of components, layout, and actions — and the client renders it with widgets it already ships. The app binary stays the same; the screen changes. This tutorial builds a small, production-shaped SDUI system in Flutter: a schema you can version, a registry-based renderer, an action/event layer, caching and offline fallback, and the guardrails that keep SDUI from turning into an unmaintainable second framework.

Assumes Flutter 3.3x+/Dart 3.x. The patterns are framework-agnostic; nothing here requires a vendor SDK.

When SDUI is the right tool (and when it isn't)

We've retrofitted SDUI into shipping apps and we've also talked clients out of it. The honest split:

Good candidates

  • Merchandising surfaces: home feeds, promo carousels, category landing screens, seasonal takeovers.
  • Forms and questionnaires whose fields change with regulation, market, or product line (KYC, insurance intake, medical screeners).
  • Onboarding and paywall experiments where you want to change order, copy, and pricing layout weekly.
  • White-label apps where each tenant needs a different arrangement of the same components.

Bad candidates

  • Camera, BLE, maps, video, or anything with heavy platform interaction and custom gestures.
  • Screens with complex local state machines (checkout with address validation and 3DS step-ups).
  • Anything where a wrong render is a safety or money incident and you need the diff in code review.

The rule we give clients: SDUI is for arrangement and content, not for behaviour. The moment your JSON starts describing conditionals, loops, and arithmetic, you have invented a bad programming language and you should ship a Dart screen instead. If you need the ability to change real logic after release, use a code-push tool like Shorebird for that specific fix rather than pushing logic into your payload.

Step 1: Design the payload schema before writing widgets

The schema is the contract between two teams and two release cadences. Get it wrong and every future change is a breaking change.

{
  "schemaVersion": 2,
  "screenId": "home",
  "etag": "a41f9c",
  "root": {
    "type": "column",
    "props": { "gap": 16, "padding": 16 },
    "children": [
      {
        "type": "hero",
        "id": "hero_spring",
        "props": {
          "title": "Spring service check",
          "subtitle": "Book before 30 April",
          "imageUrl": "https://cdn.example.com/hero/spring.webp",
          "aspectRatio": 1.9
        },
        "action": {
          "type": "navigate",
          "route": "/offers/spring",
          "analytics": { "event": "hero_tap", "campaign": "spring26" }
        }
      },
      {
        "type": "product_carousel",
        "id": "recommended",
        "props": { "title": "Recommended for you" },
        "dataRef": "recommendations.items"
      },
      {
        "type": "unknown_future_block",
        "id": "experiment_x",
        "props": {}
      }
    ]
  },
  "data": {
    "recommendations": { "items": [] }
  }
}

Four decisions are doing the work here:

  1. schemaVersion is explicit. Clients refuse payloads above the version they understand and fall back to the last good cached screen. Servers keep emitting the old version until telemetry shows the old clients are gone.
  2. Every node has a stable id. You need it for widget keys, analytics, golden tests, and bug reports ("the hero on home/hero_spring renders squashed on Pixel 6a").
  3. Presentation (props) is separated from data (data + dataRef). Layout changes and content changes then move independently, and you can cache them with different lifetimes.
  4. Unknown node types are legal. This is the single most important rule in SDUI: old clients must silently skip components they don't know, so the server can ship a new component the day the new app version starts rolling out.

Keep colours, spacing, and typography as semantic tokens ("emphasis": "primary", "gap": 16), never raw hex or font names. Otherwise your server payloads quietly become a second theme system that ignores dark mode and accessibility text scaling.

Step 2: Model the tree in Dart

Parsing is where SDUI apps usually acquire their first production crash. Treat the payload as hostile input: every field may be missing, null, or the wrong type.

class UiNode {
  const UiNode({
    required this.type,
    required this.id,
    this.props = const {},
    this.children = const [],
    this.action,
    this.dataRef,
  });

  final String type;
  final String id;
  final Map<String, dynamic> props;
  final List<UiNode> children;
  final UiAction? action;
  final String? dataRef;

  factory UiNode.fromJson(Map<String, dynamic> json) {
    final children = (json['children'] as List?) ?? const [];
    return UiNode(
      type: (json['type'] as String?)?.trim() ?? 'unknown',
      id: json['id'] as String? ?? 'anon_${identityHashCode(json)}',
      props: (json['props'] as Map?)?.cast<String, dynamic>() ?? const {},
      children: children
          .whereType<Map>()
          .map((c) => UiNode.fromJson(c.cast<String, dynamic>()))
          .toList(growable: false),
      action: json['action'] is Map
          ? UiAction.fromJson((json['action'] as Map).cast<String, dynamic>())
          : null,
      dataRef: json['dataRef'] as String?,
    );
  }
}

class UiAction {
  const UiAction({required this.type, this.params = const {}, this.analytics = const {}});

  final String type;
  final Map<String, dynamic> params;
  final Map<String, dynamic> analytics;

  factory UiAction.fromJson(Map<String, dynamic> json) => UiAction(
        type: json['type'] as String? ?? 'noop',
        params: Map<String, dynamic>.from(json)..remove('type'),
        analytics: (json['analytics'] as Map?)?.cast<String, dynamic>() ?? const {},
      );
}

Typed prop access beats scattered casts. A tiny helper keeps every builder honest:

extension Props on Map<String, dynamic> {
  String str(String key, {String fallback = ''}) {
    final v = this[key];
    return v is String ? v : fallback;
  }

  double dbl(String key, {double fallback = 0}) {
    final v = this[key];
    if (v is num) return v.toDouble();
    if (v is String) return double.tryParse(v) ?? fallback;
    return fallback;
  }

  bool flag(String key, {bool fallback = false}) {
    final v = this[key];
    return v is bool ? v : fallback;
  }
}

With Dart 3 you can go further and use sealed classes plus exhaustive switch for a fixed component set. We reach for that when the catalogue is small and stable; the registry below scales better when many teams contribute components.

Step 3: A component registry, not a giant switch

The renderer should know nothing about your components. It resolves a type string to a builder and recurses.

typedef NodeBuilder = Widget Function(BuildContext context, UiNode node, RenderScope scope);

class ComponentRegistry {
  ComponentRegistry(this._builders);

  final Map<String, NodeBuilder> _builders;

  NodeBuilder? resolve(String type) => _builders[type];

  ComponentRegistry withOverrides(Map<String, NodeBuilder> overrides) =>
      ComponentRegistry({..._builders, ...overrides});
}

class RenderScope {
  const RenderScope({required this.data, required this.onAction});

  final Map<String, dynamic> data;
  final void Function(UiAction action, UiNode node) onAction;

  Object? lookup(String? path) {
    if (path == null) return null;
    Object? cursor = data;
    for (final part in path.split('.')) {
      if (cursor is Map && cursor.containsKey(part)) {
        cursor = cursor[part];
      } else {
        return null;
      }
    }
    return cursor;
  }
}

class SduiRenderer extends StatelessWidget {
  const SduiRenderer({super.key, required this.node, required this.registry, required this.scope});

  final UiNode node;
  final ComponentRegistry registry;
  final RenderScope scope;

  @override
  Widget build(BuildContext context) {
    final builder = registry.resolve(node.type);
    if (builder == null) {
      assert(() {
        debugPrint('SDUI: unknown component "${node.type}" (${node.id})');
        return true;
      }());
      SduiTelemetry.unknownComponent(node.type, node.id);
      return const SizedBox.shrink(); // forward compatibility: skip, never crash
    }
    return KeyedSubtree(key: ValueKey(node.id), child: builder(context, node, scope));
  }
}

SizedBox.shrink() for unknown types is the forward-compatibility guarantee from Step 1, made real. Log it, count it, but never throw.

A couple of representative builders:

final registry = ComponentRegistry({
  'column': (context, node, scope) {
    final gap = node.props.dbl('gap', fallback: 12);
    return Padding(
      padding: EdgeInsets.all(node.props.dbl('padding')),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          for (var i = 0; i < node.children.length; i++) ...[
            if (i > 0) SizedBox(height: gap),
            SduiRenderer(node: node.children[i], registry: registry, scope: scope),
          ],
        ],
      ),
    );
  },
  'hero': (context, node, scope) {
    final theme = Theme.of(context);
    return Semantics(
      button: node.action != null,
      label: node.props.str('title'),
      child: InkWell(
        onTap: node.action == null ? null : () => scope.onAction(node.action!, node),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            AspectRatio(
              aspectRatio: node.props.dbl('aspectRatio', fallback: 16 / 9),
              child: CachedNetworkImage(imageUrl: node.props.str('imageUrl')),
            ),
            const SizedBox(height: 8),
            Text(node.props.str('title'), style: theme.textTheme.titleMedium),
            Text(node.props.str('subtitle'), style: theme.textTheme.bodyMedium),
          ],
        ),
      ),
    );
  },
});

Note what the builders do not do: no hex colours, no hardcoded font sizes, no MediaQuery.of(context).size.width * 0.42. They consume Theme.of(context) so dark mode, Material 3 theming, text scaling, and your design tokens keep working on server-driven screens exactly as they do on hand-written ones.

Step 4: Actions — the part teams under-design

Rendering is the easy half. The dangerous half is what happens on tap. Keep the action vocabulary small, closed, and validated client-side.

class ActionDispatcher {
  ActionDispatcher(this.router, this.analytics, this.api);

  final GoRouter router;
  final Analytics analytics;
  final ApiClient api;

  static const _allowedRoutes = {'/offers', '/product', '/support', '/settings'};

  Future<void> dispatch(UiAction action, UiNode node) async {
    analytics.log(action.analytics['event'] as String? ?? 'sdui_action', {
      'component': node.type,
      'node_id': node.id,
      ...action.analytics,
    });

    switch (action.type) {
      case 'navigate':
        final route = action.params['route'] as String? ?? '';
        if (!_isSafeRoute(route)) {
          SduiTelemetry.rejectedAction('navigate', route);
          return;
        }
        router.push(route);
      case 'open_url':
        final uri = Uri.tryParse(action.params['url'] as String? ?? '');
        if (uri == null || uri.scheme != 'https') return;
        await launchUrl(uri, mode: LaunchMode.externalApplication);
      case 'submit_form':
        await api.submitForm(action.params['formId'] as String? ?? '', node.id);
      case 'dismiss':
        router.pop();
      default:
        SduiTelemetry.unknownAction(action.type);
    }
  }

  bool _isSafeRoute(String route) =>
      route.startsWith('/') && _allowedRoutes.any((p) => route.startsWith(p));
}

Three security rules we enforce on every SDUI build, because the payload is remote input rendering into your authenticated app:

  • Allow-list routes and URL schemes. Never hand a server string to launchUrl or a deep-link handler unchecked — that is how you end up opening intent:// or a phishing page from inside a trusted app shell.
  • Never let the payload carry executable logic — no expression strings, no embedded JS, no "eval this rule". If a rule must change server-side, have the server evaluate it and send the result.
  • Serve payloads over TLS from an authenticated endpoint and treat editor access as production access. Whoever can edit a screen can change what users tap.

Add a review/approval flow in whatever CMS produces these payloads. A marketer publishing an untested layout to 100% of users at 5pm on a Friday is the SDUI failure mode we see most often, and it is a process problem, not a code problem.

Step 5: Fetch, cache, and never show a blank screen

Server-driven screens must degrade to something. The layered fallback we ship:

  1. Fresh payload from the network (ETag-conditional).
  2. Last good cached payload on disk.
  3. Bundled default payload shipped in assets.
  4. A hand-written Dart fallback screen.
class ScreenRepository {
  ScreenRepository(this.api, this.cache);

  final ApiClient api;
  final ScreenCache cache;

  static const supportedSchemaVersion = 2;

  Future<ScreenPayload> load(String screenId) async {
    final cached = await cache.read(screenId);
    try {
      final response = await api
          .getScreen(screenId, etag: cached?.etag)
          .timeout(const Duration(seconds: 3));

      if (response.notModified && cached != null) return cached;

      final payload = ScreenPayload.fromJson(response.json);
      if (payload.schemaVersion > supportedSchemaVersion) {
        SduiTelemetry.schemaTooNew(screenId, payload.schemaVersion);
        return cached ?? await _bundled(screenId);
      }
      await cache.write(screenId, payload);
      return payload;
    } on TimeoutException {
      return cached ?? await _bundled(screenId);
    } catch (e, st) {
      SduiTelemetry.loadFailed(screenId, e, st);
      return cached ?? await _bundled(screenId);
    }
  }

  Future<ScreenPayload> _bundled(String screenId) async =>
      ScreenPayload.fromJson(jsonDecode(
        await rootBundle.loadString('assets/sdui/$screenId.json'),
      ) as Map<String, dynamic>);
}

Performance notes that matter on real devices:

  • Prefetch the next screen's payload on navigation intent, so SDUI screens don't feel slower than compiled ones.
  • Keep payloads small. Under ~50 KB gzipped for a home screen; parse off the UI thread with compute() if you cross a few hundred KB.
  • Parse once, render many. Cache the UiNode tree, not just the raw JSON string, for the lifetime of the screen.
  • Use lazy lists. A server-driven feed of 200 nodes must map to ListView.builder slivers, not a Column inside a SingleChildScrollView.
  • Keep ValueKey(node.id) on every node so reorders reuse elements rather than rebuilding subtrees.

Step 6: Test it like a compiler, not like a screen

SDUI moves risk from compile time to run time, so your tests have to move with it.

Contract tests on the server side. Validate every published payload against a JSON Schema in CI. Unknown component types are allowed at runtime, but they should never be accidental — the publish pipeline should warn when a payload uses a component that no released client version supports yet.

Golden tests per component, driven by fixture JSON.

testWidgets('hero renders title, subtitle and image slot', (tester) async {
  final node = UiNode.fromJson(jsonDecode(fixture('hero_spring.json')));
  await tester.pumpWidget(wrapForTest(
    SduiRenderer(node: node, registry: registry, scope: testScope),
  ));
  await expectLater(find.byType(SduiRenderer), matchesGoldenFile('goldens/hero_spring.png'));
});

A forward-compatibility test that must never be deleted.

testWidgets('unknown component types are skipped, not fatal', (tester) async {
  final node = UiNode.fromJson(jsonDecode('''
    {"type":"column","id":"root","children":[
      {"type":"component_from_the_future","id":"x"},
      {"type":"hero","id":"h","props":{"title":"Still here"}}
    ]}'''));
  await tester.pumpWidget(wrapForTest(
    SduiRenderer(node: node, registry: registry, scope: testScope),
  ));
  expect(tester.takeException(), isNull);
  expect(find.text('Still here'), findsOneWidget);
});

Fuzz the parser. Feed truncated payloads, nulls in every field, wrong types, and 10-level-deep nesting. A parser that survives a few hundred mutated fixtures will survive your CMS.

Accessibility checks on rendered trees. Run meetsGuideline(textContrastGuideline) and tap-target guidelines against golden fixtures; a content editor can otherwise ship an inaccessible screen without any engineer seeing it.

Step 7: Ship it safely

  • Version components, don't mutate them. hero v1 and hero_v2 can coexist; changing hero's prop meaning breaks every installed client.
  • Track client coverage. Your CMS should show "this component is supported by 94% of active installs" before publish. That number comes from an analytics property carrying the client's supported schema version and registry hash.
  • Roll out by cohort. 1% → 10% → 50% → 100%, with automatic rollback on a crash-rate or unknown-component-rate spike.
  • Kill switch per screen. A server flag that forces the bundled/native fallback. When a payload breaks in the wild at 22:00, you want one toggle, not a hotfix.
  • Instrument render failures as first-class production signals: unknown component rate, parse failure rate, fallback-served rate, and time-to-first-render per screen id. Feed them into the same crash/observability stack as the rest of the app.

Store policy: is this allowed?

Yes — with a limit. Apple's guideline 2.5.2 and Google's device-and-network-abuse policy both prohibit downloading executable code that changes the app's purpose. Sending layout and content data that your shipped binary interprets with its own widgets is ordinary app behaviour — it's what every news, e-commerce, and social app already does. Where teams get into trouble is embedding scripting engines or fetching logic that introduces features the review never saw. Stay on the data side of the line and the metadata you declare stays accurate.

What this costs

Be honest with stakeholders about the trade. You gain: layout and content changes in minutes instead of a release cycle, experiments without binary variants, and per-tenant screens from one codebase. You pay: a schema to maintain, a CMS or admin surface to build, a second testing discipline, weaker compile-time safety, and a debugging story where "what did the user actually see?" requires capturing the payload alongside the crash.

Our rule of thumb from client work: SDUI pays for itself when a specific surface changes more than once a month and is owned by a non-engineering team. For anything else, a well-factored Dart screen plus feature flags is cheaper, faster, and safer. Start with one screen, prove the pipeline end to end — schema, CMS, renderer, telemetry, kill switch — and only then expand the component catalogue.


Weighing server-driven UI for a Flutter app, or trying to tame an SDUI layer that has outgrown its schema? Get in touch — our Flutter consultants design component catalogues, renderers, and release guardrails that product teams can actually use.