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
mobilesection to itsmodule.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)
- Web:
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:
- Convert the URL slug to a screen ID (e.g.
/m/products/listbecomesproducts/list) - Call
GET /mobile/screens/{screenId}to check if an SDUI definition exists - If found, render via
ScreenRendererinsideThemedLayout - 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:
- Write the screen provider PHP class on the backend
- Register it in
module.json - Run
php artisan module:sync - 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/listto screen IDproducts/list, renders with shadcn components - Mobile:
/(app)/m/[...slug]converts the same path to screen IDproducts.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:
- Search —
searchable: trueadds a search bar with debounced server-side search - Filters —
filters: [...]adds horizontally scrollable filter chips - Pagination —
pagination: trueadds a "Load More" button - Tap action —
on_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
- Web SDUI Renderer — web-specific implementation reference
- Creating Mobile Screens — step-by-step guide for module developers
- Mobile App Module — push notifications, voice assistant, settings
- Module Development — general module development guide
- Widgets — dashboard widget system (similar pattern)