DEV Community

Aakash Gupta
Aakash Gupta

Posted on • Originally published at Medium

Our Home Screen Renders 12 Components — Without Knowing Any of Them

The Widget Factory pattern that lets our CMS control what 2M+ users see — without a single app release.


Our home screen serves millions of page views per month. It renders carousels, banners, sign-in cards, feeds, grids, and more.

The screen has no idea what it's rendering.

A content team in a CMS dashboard decides what appears, in what order, for which users — and the app renders it. No code change. No app release. No PR review. Here's the architecture that makes that possible.


The Problem: Every Home Screen Change Required a Release

Twelve months ago, our home screen was a Column with hardcoded widgets. Product carousel at the top, banner in the middle, categories at the bottom. Always. For everyone.

It wasn't completely static — data came from the CMS — but the layout was frozen in code. The feed widget alone was 350+ lines of nested if blocks:

if (banners != null && shouldShowBannerCarousel) ...
if (shouldShowMosaicGrid) ...
if (isGuestUser && shouldShowSecondaryMosaic) ...
if (ugcPosition == FeedPosition.top) ...
if (categories != null) ...
// and on and on
Enter fullscreen mode Exit fullscreen mode

There was even a TODO buried in the file: "Create a method that returns home feed widgets dynamically." We'd been telling ourselves to fix it for months.

Want to swap the banner and carousel? Code change. Want to show a sign-in card only to guest users? Another if block. Want to A/B test a new component position? Two code changes and a feature flag.

The content team had ideas faster than we could ship them. We were the bottleneck — and every "quick layout tweak" took a sprint.

My colleague Dip and I got the assignment: make the home screen data-driven. The CMS should define what appears and where. The app should just render it.


The Architecture: Four Layers, One Factory

Here's the data flow Dip and I designed, from CMS to screen:

Flow diagram showing CMS-driven UI rendering: Contentful CMS → GraphQL API → DTO layer → domain entities → BLoC → ListView → widget factory → dynamically rendered UI components.

The critical insight: the ListView doesn't care what types exist. It iterates a list and delegates every item to a WidgetFactory. The factory resolves "ProductCarousel"CarouselWidgetBuilderCarouselWidget. The screen never imports a single component.

Think of it as: CMS sends JSON → app turns it into widgets. Everything in between is just type safety and caching.


The Widget Factory: O(1) Type Resolution

The factory is a singleton with a pre-built lookup map. On init, it iterates an enum of supported types and caches each builder:

class WidgetFactory {
  static final WidgetFactory _instance = WidgetFactory._internal();
  static WidgetFactory get instance => _instance;

  late final Map<String, IWidgetBuilder> _builderCache;

  WidgetFactory._internal() {
    _builderCache = {
      for (final model in SupportedComponentType.values)
        model.cmsId: model.widgetBuilder,
    };
  }

  Widget createWidget(BuildContext context, LayoutWidgetEntity item) {
    final builder = _builderCache[item.contentModelType];
    if (builder == null || !builder.canHandle(item)) {
      return const SizedBox.shrink(); // Unknown type? Skip silently.
    }
    return builder.build(context, item);
  }
}
Enter fullscreen mode Exit fullscreen mode

No switch statement. No if-else chain. A Map lookup — O(1) regardless of how many component types we add. Unrecognized types from the CMS render as empty space, not crashes.

Flow of widget rendering


The Registry: One Enum to Rule Them All

Every supported component type lives in a single enum. Each entry maps a CMS string to a concrete builder:

enum SupportedComponentType {
  productCarousel,
  signInCard,
  contentBanner,
  userContent,
  contextualMessage,
  infoBanner,
  recentActivity,
  mosaicGrid,
  promotionBanner,
  categoryCarousel,
  // ...12 types total

  String get cmsId => switch (this) {
    productCarousel => 'ProductCarousel',
    signInCard => 'SignInCard',
    contentBanner => 'ContentBanner',
    // ...
  };

  IWidgetBuilder get widgetBuilder => switch (this) {
    productCarousel => CarouselWidgetBuilder(),
    signInCard => SignInCardWidgetBuilder(),
    // ...
  };
}
Enter fullscreen mode Exit fullscreen mode

Adding a new component type = one enum entry + one builder class. The factory, the screen, the cache, the BLoC — none of them change. Dip and I have watched teammates add a new component in under two hours. Most of that time was writing the widget itself.


Each Component is an Island

Every builder wraps its widget in its own BlocProvider. The product carousel gets a CarouselBloc. The announcement banner gets an AnnouncementBloc. They share nothing.

class CarouselWidgetBuilder implements IWidgetBuilder {
  @override
  bool canHandle(LayoutWidgetEntity layout) {
    return layout.contentModelType == 'ProductCarousel' 
        && layout.sysId.isNotEmpty;
  }

  @override
  Widget build(BuildContext context, LayoutWidgetEntity entity) {
    return MultiBlocProvider(
      providers: [
        BlocProvider(create: (_) => getIt<CarouselBloc>()),
        BlocProvider(create: (_) => getIt<CarouselItemBloc>()),
      ],
      child: CarouselWidget(widgetEntity: entity),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This isolation didn't happen on day one. We initially tried sharing BLoCs between components — it broke within a week. One component's loading state leaked into another's UI. That's when Dip and I enforced full isolation: if you're a component, you bring your own state management.


Polymorphic Data: Same Widget, Seven Sources

Here's the part that surprised both of us. The same ProductCarousel component renders data from seven different backends — same UI, different data sources selected via CMS, zero code changes:

Data Source What It Shows
CMS Editorially curated products
PersonalizationBFF Personalized for you
NewArrivalsBFF New products
RepurchaseBFF Previously purchased
TrendingBFF Trending items
ComplementaryBFF "Goes well with..."
CategoryBFF Category-based suggestions

The CarouselBloc reads entity.dataSourceType and calls the right repository. The widget doesn't know or care where its products came from. The content team picks the data source in the CMS dropdown — the same carousel component renders personalized recommendations or editorial picks depending on configuration.


Audience Filtering: The Invisible Gatekeeper

Not every user should see every component. Guest users see a sign-in card. Loyalty members see exclusive offers. The CMS attaches audience rules to each component:

fragment audienceFields on AudienceRules {
  premiumMember
  loyaltyMember
  dualMembership
  loggedInUser
  guestUser
}
Enter fullscreen mode Exit fullscreen mode

During DTO→Entity conversion, we evaluate these rules against the current user's state. Dip built the audience resolution logic — clean, single-pass, no widget-level branching. If the user doesn't match, the entity's isVisible flag is set to false. The ListView checks this flag before rendering:

itemBuilder: (context, index) {
  final widget = layout[index];
  return widget.isVisible
      ? WidgetFactory.instance.createWidget(context, widget)
      : const SizedBox.shrink();
}
Enter fullscreen mode Exit fullscreen mode

Filtering happens once, at the data boundary — not in each component, not in the UI layer, not scattered across widgets. One place.


What We'd Do Differently

Preloading. Right now, each component fetches its own data when it scrolls into view. A component at position 8 doesn't start loading until the user scrolls there. We'd add viewport-aware preloading — start fetching components 2-3 positions ahead of the current scroll position.

Error boundaries per component. We handle errors gracefully, but a bad API response from one data source can still show an empty slot. We'd wrap each builder output in an error boundary widget that shows a minimal fallback instead of empty space.


Takeaways

  • Let the CMS define layout, not your code. If your home screen changes require app releases, you've coupled content to code.
  • Factory + Registry > switch statements. An enum-driven registry scales to 50 component types without touching the factory.
  • Isolate components with their own BLoCs. No shared state, no cascade failures, parallel development.
  • Filter at the data boundary, not the UI. Audience rules belong in the DTO→Entity conversion, not scattered across widgets.
  • Cache aggressively, refresh silently. Users don't care about freshness on a 200ms cold start. They care about seeing something immediately.

Our home screen went from "every change is a sprint" to "the content team ships layout changes before Dip and I finish our coffee."

The app doesn't know what components exist. And that's exactly when you know you've finally decoupled your UI.


Tags: Flutter, Mobile Development, Software Architecture, Clean Architecture, CMS


Top comments (0)