Server-Driven UI (SDUI)

The SDUI system allows modules to define screens as JSON on the backend. Both the web frontend and the mobile app fetch these definitions at runtime and render them using platform-native component registries. No frontend rebuild, app binary rebuild, or store submission is required.

Overview

  • Backend defines screens as JSON (layout, sections, fields, actions)
  • Web frontend fetches definitions via API, renders using shadcn/Tailwind components
  • Mobile app fetches definitions via API, renders using React Native components
  • Any module can contribute screens by adding a mobile section to its module.json
  • Core screens (dashboard, orders, settings) remain static files for max performance
  • Module screens render dynamically via catch-all routes on both platforms:
    • Web: /m/[...slug] (Next.js catch-all page)
    • Mobile: /(app)/m/[...slug] (Expo Router catch-all)

Architecture

Module module.json
  └─ "mobile": { screens: [...], screenProviders: {...} }
       │
       ▼
ModuleExtensionLoader (boot phase)
  └─ Reads module.json → registers in MobileScreenRegistry
       │
       ▼
API Endpoints (MobileScreenController)
  ├─ GET /mobile/screens                    → list all screens
  ├─ GET /mobile/screens/manifest           → versioned cache manifest
  ├─ GET /mobile/screens/{id}               → SDUI JSON definition
  ├─ GET /mobile/screens/{id}/data/{section} → section data
  └─ POST /mobile/screens/{id}/action/{id}  → execute action
       │
       ├──────────────────────┐
       ▼                      ▼
  Web Frontend            Mobile App
  /m/products/list        /(app)/m/products/list
       │                       │
  ScreenRenderer          ScreenRenderer
       │                       │
  Section Registry        Section Registry
  (shadcn components)     (RN components)

Web Renderer

The web SDUI engine lives at frontend/lib/sdui/ and renders the same backend JSON as the mobile app, but using shadcn/ui and Tailwind CSS components instead of React Native primitives.

How /m/[...slug] Works

The Next.js catch-all route at frontend/app/m/[...slug]/page.tsx resolves screens in this order:

  1. Convert the URL slug to a screen ID (e.g. /m/products/list becomes products/list)
  2. Call GET /mobile/screens/{screenId} to check if an SDUI definition exists
  3. If found, render via ScreenRenderer inside ThemedLayout
  4. If not found, fall back to TenantModulePage (tenant-uploaded module loader)

This means SDUI screens take priority, but tenant-uploaded JavaScript bundles still work as a fallback.

Web Section Components

The web renderer ships with 13 section components, all built on shadcn/ui:

Type shadcn Components Used Notes
stats-row Card Responsive grid (2 cols mobile, 4 cols desktop)
list Card, Input, Badge, Button Search bar, filter chips, pagination, tap actions
grid Card Configurable column count
detail-fields Card, Badge Key-value rows with field formatting
form Card, Input, Select, Switch, Button Dynamic form with validation
card Card Single content card with icon
chart Card Bar/line/pie charts
timeline Card Status timeline with dots and lines
actions-bar Button Horizontal button row with variants
empty-state Centered icon + message placeholder
banner Alert Info/warning/error/success alert banners
tabs Tabs Tab switcher with nested SDUI sections
image-gallery Horizontal image carousel

The mobile-only camera-scanner type is not registered on web (gracefully skipped).

No Frontend Rebuild Required

Because the web renderer fetches screen definitions from the API at runtime, adding a new module with SDUI screens only requires:

  1. Write the screen provider PHP class on the backend
  2. Register it in module.json
  3. Run php artisan module:sync
  4. Rebuild the backend Docker image (if using Docker)

The frontend image does not need to be rebuilt. Users navigate to /m/{module}/{page} and the catch-all route handles everything dynamically.

For the full web-specific reference, see Web SDUI Renderer.

How It Works

1. Module Registers Screens

In module.json, add a mobile section:

{
  "mobile": {
    "screens": [
      {
        "id": "products.list",
        "title": "Products",
        "icon": "Box",
        "layout": "scroll",
        "route": "/products",
        "version": 1
      }
    ],
    "screenProviders": {
      "products.list": "Modules\\Products\\App\\MobileScreens\\ProductListScreen"
    }
  }
}

2. Screen Provider Returns JSON

The provider class implements MobileScreenProviderContract and returns a SDUI definition:

class ProductListScreen extends BaseMobileScreenProvider
{
    public function getScreenId(): string
    {
        return 'products.list';
    }

    public function getDefinition(array $context = []): array
    {
        return [
            'id' => 'products.list',
            'version' => 1,
            'type' => 'screen',
            'title' => 'Products',
            'layout' => 'scroll',
            'header' => ['title' => 'Products', 'show_back' => true],
            'sections' => [
                ['id' => 'stats', 'type' => 'stats-row', 'items' => [...]],
                ['id' => 'list', 'type' => 'list', 'fields' => [...], 'searchable' => true],
            ],
            'pull_to_refresh' => true,
        ];
    }

    public function getData(string $sectionId, array $params = [], array $context = []): array
    {
        // Return data for the requested section
    }
}

3. Client Renders the Screen

Both platforms use a catch-all route that converts URL segments to a screen ID, fetches the definition from the API, and passes it to a ScreenRenderer:

  • Web: /m/[...slug] converts /m/products/list to screen ID products/list, renders with shadcn components
  • Mobile: /(app)/m/[...slug] converts the same path to screen ID products.list, renders with React Native components

The renderer maps each section's type string to a registered platform-native component via the section registry.

API Endpoints

All endpoints require authentication and tenant context.

List Screens

GET /api/v1/mobile/screens

Returns metadata for all registered screens.

Screen Manifest

GET /api/v1/mobile/screens/manifest

Returns a versioned manifest for bulk cache synchronization. The version field is an MD5 hash that changes when any screen's version changes.

Screen Definition

GET /api/v1/mobile/screens/{screenId}

Returns the full SDUI JSON definition including header, sections, and layout.

Section Data

GET /api/v1/mobile/screens/{screenId}/data/{sectionId}

Returns data for a specific section. Supports query parameters:

Param Description
page Page number for paginated sections
per_page Items per page (max 50)
search Search query for searchable sections
status Filter by status (module-specific)
sort Sort order (newest, oldest, price_asc, etc.)

Execute Action

POST /api/v1/mobile/screens/{screenId}/action/{actionId}

Executes a server-side action (archive, status change, etc.) and returns the result.

Screen Definition Schema

{
  "id": "products.list",
  "version": 3,
  "type": "screen",
  "title": "Products",
  "layout": "scroll",
  "header": {
    "title": "Products",
    "subtitle": "Manage your catalog",
    "show_back": true,
    "actions": [
      {
        "id": "add",
        "icon": "Plus",
        "label": "Add",
        "action": { "type": "navigate", "route": "/(app)/m/products/create" }
      }
    ]
  },
  "sections": [...],
  "pull_to_refresh": true,
  "cache_ttl": 300
}

Section Types

Both platforms ship with pre-built section components (14 on mobile, 13 on web). The JSON schema is identical — each platform's section registry maps type strings to native components.

Type Description Use Case
stats-row Horizontal scrolling stat cards Overview numbers
list Paginated card list with search/filters Product, order, customer lists
grid Card grid with configurable columns Category grids, feature tiles
detail-fields Key-value rows in a card Order/product detail
form Dynamic input form Create/edit screens
card Single content card Info display
chart Bar chart Analytics
timeline Status timeline with dots/lines Order status history
actions-bar Horizontal button row Action buttons
empty-state Empty placeholder No data states
banner Alert banner (info/warning/error/success) Notices
tabs Tab switcher with nested sections Sub-sections
camera-scanner Barcode scan placeholder Product lookup
image-gallery Horizontal image carousel Product images

Action Types

Actions are triggered by tapping list items, buttons, or header actions.

Type Description Key Fields
navigate Push a route route, params
api-call Call a backend endpoint method, endpoint, body, confirm
open-modal Open a SDUI screen in a modal screen_id
open-sheet Open a SDUI screen in a bottom sheet screen_id
share Native share dialog text
copy Copy to clipboard text or value
call Open phone dialer value
email Open email client value
refresh Refresh current screen screen_id (optional)
back Navigate back
external-url Open in browser url

Template Interpolation

Action fields support {{key}} placeholders that are replaced with row data:

{
  "type": "navigate",
  "route": "/(app)/m/products/detail",
  "params": { "id": "{{id}}" }
}

Confirmation Dialogs

Any action can include a confirm object to show a native alert before executing:

{
  "type": "api-call",
  "endpoint": "/products/{{id}}/status",
  "body": { "status": "archived" },
  "confirm": {
    "title": "Archive Product?",
    "message": "This will hide the product from your store."
  }
}

Field Types

Fields define how data values are displayed in list items and detail rows.

Type Description Extra Options
text Plain text prefix, suffix
number Formatted number prefix, suffix
currency Currency formatted currency (e.g. "INR")
date Localized date
datetime Localized date + time
relative-time "2h ago", "3d ago"
badge Colored status pill badge_colors map
image Thumbnail image image_size (sm/md/lg)
boolean Yes/No badge
percent Percentage
weight Weight with unit suffix (default: kg)
color Color swatch + hex

Badge Colors

Map status values to hex colors. The app automatically resolves them to Badge variants (success, warning, destructive, secondary):

{
  "type": "badge",
  "badge_colors": {
    "active": "#16A34A",
    "draft": "#6B7280",
    "archived": "#EF4444"
  }
}

List Section Features

The list section supports:

  • Searchsearchable: true adds a search bar with debounced server-side search
  • Filtersfilters: [...] adds horizontally scrollable filter chips
  • Paginationpagination: true adds a "Load More" button
  • Tap actionon_tap: {...} navigates when a row is tapped

Filter Definition

{
  "filters": [
    {
      "key": "status",
      "label": "Status",
      "type": "select",
      "options": [
        { "label": "All", "value": "all" },
        { "label": "Active", "value": "active" },
        { "label": "Draft", "value": "draft" }
      ]
    },
    {
      "key": "sort",
      "label": "Sort",
      "type": "select",
      "options": [
        { "label": "Newest", "value": "newest" },
        { "label": "Price: Low", "value": "price_asc" }
      ]
    }
  ]
}

Caching

Backend

BaseMobileScreenProvider caches screen definitions via Laravel Cache with configurable TTL (default 300s). Override $definitionCacheTtl in your provider.

Web

The web renderer does not cache definitions client-side. It fetches fresh definitions on every page load, relying on backend cache and HTTP caching headers for performance.

Mobile App

Two-layer cache:

Layer Storage Speed Persistence
Memory In-memory Map Instant Lost on restart
Disk AsyncStorage ~5ms Survives restart

The screen renderer always fetches fresh definitions from the API. Cache is used as offline fallback. Manifest-based invalidation evicts stale definitions when screen versions change.

Sidebar Integration

The app sidebar automatically discovers SDUI screens. When the sidebar opens, it fetches GET /mobile/screens and displays entry-point screens (excluding detail/edit/create screens) as first-class navigation items alongside Dashboard, Orders, Customers, etc.

Overlay Manager

Actions of type open-modal or open-sheet render a SDUI screen inside a Modal or BottomSheet overlay. The SDUIOverlayManager wraps the app root and listens for events from the action handler via a typed event bus.

Related Documentation