SDUI Flutter Sanity

A Server-Driven UI (SDUI) Flutter package powered by Sanity.io headless CMS with realtime config updates.

Sanity.io stores the component tree for each screen as structured content. The Flutter app renders it live β€” including picking up edits published in Sanity Studio in realtime, with no app rebuild required.


Features

  • 🎨 Dynamic widget rendering powered by Sanity.io GROQ queries.
  • ⚑ Real-time updates via Server-Sent Events (SSE) with exponential backoff.
  • πŸ’Ύ Offline-first local caching via Hive for last-known-good config.
  • πŸ”Œ Extensible β€” register custom components and action handlers.
  • πŸ›  CLI code generator β€” scaffold Flutter widgets + Sanity schemas in one command.
  • 🧩 State management agnostic β€” works with Riverpod, Bloc, Provider, or anything.

Installation

Add to your pubspec.yaml:

dependencies:
  sdui_flutter_sanity: ^0.3.0

Quick Start

1. Initialize the Engine

In your main.dart, initialize the engine before runApp. Register any custom components and action handlers here.

import 'package:flutter/material.dart';
import 'package:sdui_flutter_sanity/sdui_flutter_sanity.dart';

// Import generated components and action handlers
import 'sdui_components/product_card_component.dart';
import 'sdui_actions/add_to_cart_action_handler.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await SduiEngine.initialize(
    config: const SduiConfig(
      projectId: 'your_sanity_project_id',
      dataset: 'production',
      // token: 'YOUR_READ_TOKEN', // only needed for private datasets
    ),
    components: [
      ProductCardComponent(),   // <-- Generated by CLI or hand-written
    ],
    actionHandlers: [
      AddToCartActionHandler(), // <-- Generated by CLI or hand-written
    ],
  );

  runApp(const MyApp());
}

2. Render a Screen

Drop SduiView anywhere in your widget tree. It fetches the Sanity document for the given slug, caches it locally, and subscribes to live updates automatically.

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Home')),
        body: SduiView(
          slug: 'home',          // Matches a Page Config document slug in Sanity
          scrollable: true,      // Wraps in SingleChildScrollView (default: true)
          padding: EdgeInsets.all(16),
          loadingWidget: const Center(child: CircularProgressIndicator()),
          errorBuilder: (ctx, err, stack) => Center(child: Text('$err')),
        ),
      ),
    );
  }
}

CLI Code Generator

The fastest way to add custom components or actions. Run from your project root:

dart run sdui_flutter_sanity:generate

The interactive CLI will ask a few questions and generate everything automatically.

=== SDUI Code Generator ===
Commands:
  c / component  β€” scaffold a Flutter component + Sanity schema
  a / action     β€” scaffold a Flutter action handler
  s / seed       β€” generate a sample .ndjson to seed a fresh Sanity project

Generating a Component

=== SDUI Code Generator ===
Generate a Component (c) or Action (a)? [c]: c
Component name (e.g. product_card): product_card
Fields (comma separated name:type, e.g. title:string, price:number): title:string, price:number, imageUrl:image, inStock:boolean
Has children? (y/n) [n]: n
Flutter output directory [lib/sdui_components]:
Sanity schema directory [sanity_studio/schemas]:

βœ… Generated Dart Component: lib/sdui_components/product_card_component.dart
βœ… Generated Sanity Schema:  sanity_studio/schemas/components/productCardComponent.ts
βœ… Updated uiElements.ts
βœ… Updated index.ts

πŸŽ‰ Done! Don't forget to register your new ProductCardComponent in SduiEngine.initialize()

Supported field types:

Type Sanity Schema Type Dart Type
string string String
text text (multi-line) String
number number double
boolean boolean bool
image image String (URL)
url string String
color string String?

The generator auto-patches uiElements.ts and index.ts so you never have to touch those files manually.

What gets generated

lib/sdui_components/product_card_component.dart β€” A fully typed Flutter component stub:

import 'package:flutter/material.dart';
import 'package:sdui_flutter_sanity/sdui_flutter_sanity.dart';

class ProductCardComponent extends SduiComponent {
  @override
  String get type => 'productCard'; // Matches Sanity schema initialValue

  @override
  Widget build(BuildContext context, UiNode node, Widget Function(UiNode) buildChild) {
    final title = node.props['title'] as String? ?? '';
    final price = (node.props['price'] as num?)?.toDouble() ?? 0.0;
    final imageUrl = node.props['imageUrl'] as String? ?? '';
    final inStock = node.props['inStock'] as bool? ?? false;

    // TODO: Implement your custom widget using the extracted properties.
    return const Placeholder();
  }
}

sanity_studio/schemas/components/productCardComponent.ts β€” The matching Sanity schema:

import {defineField, defineType} from 'sanity'

export default defineType({
  name: 'productCardComponent',
  title: 'Product Card',
  type: 'object',
  fields: [
    defineField({ name: 'type', type: 'string', hidden: true, initialValue: 'productCard' }),
    defineField({
      name: 'props',
      title: 'Properties',
      type: 'object',
      fields: [
        defineField({ name: 'title',    title: 'Title',    type: 'string' }),
        defineField({ name: 'price',    title: 'Price',    type: 'number' }),
        defineField({ name: 'imageUrl', title: 'Image Url', type: 'image' }),
        defineField({ name: 'inStock',  title: 'In Stock', type: 'boolean' }),
      ],
    }),
  ],
  preview: {
    select: {subtitle: 'props.title'},
    prepare(selection) {
      return {title: 'Product Card', subtitle: selection.subtitle}
    },
  },
})

Generating an Action Handler

Generate a Component (c) or Action (a)? [c]: a
Action name (e.g. add_to_cart): add_to_cart
Flutter output directory [lib/sdui_actions]:

βœ… Generated Dart Action Handler: lib/sdui_actions/add_to_cart_action_handler.dart

πŸŽ‰ Done! Don't forget to register your new AddToCartActionHandler in SduiEngine.initialize()

lib/sdui_actions/add_to_cart_action_handler.dart:

import 'package:flutter/material.dart';
import 'package:sdui_flutter_sanity/sdui_flutter_sanity.dart';

class AddToCartActionHandler implements SduiActionHandler {
  @override
  bool canHandle(SduiAction action) => action.type == 'add_to_cart';

  @override
  void handle(BuildContext context, SduiAction action) {
    final payload = action.payload; // e.g. a product ID
    final params  = action.params;  // optional extra fields from CMS

    // TODO: Implement your custom action logic.
    // Works with any state management: Riverpod, Bloc, Provider, etc.
    debugPrint('AddToCartActionHandler: Handled action with payload $payload');
  }
}

Writing Custom Components Manually

If you prefer not to use the CLI, you can implement SduiComponent directly:

import 'package:flutter/material.dart';
import 'package:sdui_flutter_sanity/sdui_flutter_sanity.dart';

class PromoBannerComponent extends SduiComponent {
  @override
  String get type => 'promoBanner'; // Must match the Sanity schema 'initialValue'

  @override
  Widget build(BuildContext context, UiNode node, Widget Function(UiNode) buildChild) {
    final headline = node.props['headline'] as String? ?? '';
    final subtitle = node.props['subtitle'] as String? ?? '';
    final bgColor  = node.props['bgColor'] as String? ?? '#6200EA';

    // Extract an optional CMS-defined action (e.g. navigate to a promo page)
    final action = SduiAction.tryParse(node.props['action']);

    return GestureDetector(
      onTap: () {
        if (action != null) {
          // Dispatch to any registered SduiActionHandler
          SduiEngine.handleAction(context, action);
        }
      },
      child: Container(
        padding: const EdgeInsets.all(20),
        color: Color(int.parse(bgColor.replaceFirst('#', '0xFF'))),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(headline, style: Theme.of(context).textTheme.headlineSmall),
            Text(subtitle,  style: Theme.of(context).textTheme.bodyMedium),
            // Recursively render any CMS children embedded in this component
            ...node.children.map(buildChild),
          ],
        ),
      ),
    );
  }
}

Writing Custom Action Handlers Manually

import 'package:flutter/material.dart';
import 'package:sdui_flutter_sanity/sdui_flutter_sanity.dart';

class AddToCartActionHandler implements SduiActionHandler {
  @override
  // The `type` must match the prefix of the CMS action string, e.g. "cart_add:product-123"
  bool canHandle(SduiAction action) => action.type == 'cart_add';

  @override
  void handle(BuildContext context, SduiAction action) {
    final productId = action.payload; // e.g. "product-123"
    final quantity  = action.params['qty'] as int? ?? 1;

    // Use any state management β€” the package doesn't care!
    // context.read<CartNotifier>().add(productId, quantity);

    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Added $productId (qty: $quantity) to cart!')),
    );
  }
}

CMS Action String formats accepted:

  • "cart_add:product-123" β€” legacy colon-separated string
  • {"type": "cart_add", "payload": "product-123", "params": {"qty": 2}} β€” structured Map

Sanity Studio Setup

1. Create a Studio project

npm create sanity@latest

2. Copy the bundled schemas

Copy the entire sanity_studio/schemas/ folder from this repository into your Sanity project's schemaTypes/ or schemas/ folder. This gives you:

  • pageConfig.ts β€” Schema for a single app screen.
  • components/uiElements.ts β€” Array schema listing all available UI component types.
  • components/*.ts β€” Individual schemas for text, image, button, column, row, card, spacer.

A fresh Sanity project has no documents, so your Flutter app will show a loading spinner with nothing to render. The CLI can generate a ready-to-import .ndjson seed file that pre-populates two sample pages showcasing every built-in component:

dart run sdui_flutter_sanity:generate
# Choose: s (seed)
# Output path: sdui_seed.ndjson

Then import it into your Sanity dataset:

cd your_sanity_studio_dir
sanity dataset import /path/to/sdui_seed.ndjson production --replace

This creates two published pageConfig documents:

Slug Content
home Heading, body text, image, two stat cards (row), feature card, CTA buttons
catalog Product list with image cards using text, row, card, button

Your Flutter app will immediately render them via SduiView(slug: 'home') β€” no app changes needed.

Tip: The seed documents are real Sanity documents. Open Sanity Studio after importing to see them and edit freely.

4. Export the schemas

Ensure your schemaTypes/index.ts exports all schemas:

import pageConfig from './pageConfig'
import textComponent from './components/textComponent'
import imageComponent from './components/imageComponent'
import buttonComponent from './components/buttonComponent'
import spacerComponent from './components/spacerComponent'
import columnComponent from './components/columnComponent'
import rowComponent from './components/rowComponent'
import cardComponent from './components/cardComponent'
import uiElements from './components/uiElements'

export const schemaTypes = [
  pageConfig,
  textComponent,
  imageComponent,
  buttonComponent,
  spacerComponent,
  columnComponent,
  rowComponent,
  cardComponent,
  uiElements,
]

Custom components added via the CLI are auto-imported here.

4. Run the Studio and create content

npm run dev
  1. Open http://localhost:3333.
  2. Create a new Page Config document.
  3. Set the Slug to home (or any screen identifier your Flutter app uses).
  4. Build the Layout by adding components from the CMS.
  5. Click Publish β€” the Flutter app will immediately render the update via SSE.

SduiConfig Reference

SduiConfig(
  projectId:        'abc123',       // Required. Your Sanity project ID.
  dataset:          'production',   // Optional. Default: 'production'.
  apiVersion:       'v2024-01-01',  // Optional. Sanity API version.
  token:            'sk...',        // Optional. Required for private datasets.
  enableCache:      true,           // Optional. Hive disk cache. Default: true.
  supportedVersion: 1,              // Optional. Schema version guard. Default: 1.
)

SduiView Reference

SduiView(
  slug:          'home',           // Required. Sanity Page Config slug.
  scrollable:    true,             // Wraps in SingleChildScrollView. Default: true.
  padding:       EdgeInsets.all(16), // Padding around the rendered tree. Default: 16.
  loadingWidget: CircularProgressIndicator(), // Shown while fetching. Default: spinner.
  errorBuilder: (ctx, err, stack) => Text('$err'), // Custom error UI.
)

Core Use Cases

1. E-Commerce & Promotional Campaigns

Marketing teams use Sanity Studio to update banners, hero layouts, and product carousels β€” no developer involvement, no app store review. Changes arrive on-device in milliseconds via SSE.

2. A/B Testing & Personalization

Pass different slug values to SduiView based on user segments (premium vs free, region, etc.) to serve entirely different UI trees from the same Flutter code.

3. Feature Flags & Phased Rollouts

Wrap new features in a custom SDUI component. Add or remove it from the Sanity layout to toggle the feature for 100% of live users instantly β€” no code change, no deployment.


State Management Agnostic

SDUI Flutter Sanity does not force any state management library. Custom components return standard Flutter widgets, so you can use:

// Riverpod
class MyComponent extends SduiComponent {
  @override Widget build(ctx, node, buildChild) =>
    Consumer(builder: (ctx, ref, _) {
      final cart = ref.watch(cartProvider);
      return Text('${cart.count} items');
    });
}

// Bloc
class MyComponent extends SduiComponent {
  @override Widget build(ctx, node, buildChild) =>
    BlocBuilder<CartBloc, CartState>(
      builder: (ctx, state) => Text('${state.count} items'),
    );
}