Web SDUI Renderer
The web SDUI renderer allows modules to define pages as JSON on the backend and have them rendered on the web frontend using shadcn/ui and Tailwind CSS. It consumes the same API endpoints and JSON schema as the mobile SDUI system, but renders with web-native components instead of React Native primitives.
Overview
- Same JSON, different components — the backend serves identical screen definitions to web and mobile
- No frontend rebuild — new modules with SDUI screens only require a backend deploy
- Catch-all route at
/m/[...slug]resolves SDUI screens first, falls back to tenant module loader - 13 section components built on shadcn/ui (Card, Badge, Input, Button, Tabs, Alert, etc.)
- Action handler supports navigate, API calls, clipboard, share, refresh, and more
Architecture
Browser navigates to /m/products/list
│
▼
/m/[...slug]/page.tsx (catch-all route)
│
├─ Convert slug to screenId: "products/list"
├─ GET /api/v1/mobile/screens/products/list
│
├─ Found? → ScreenRenderer (SDUI mode)
│ ├─ Render header (back button, title, actions, refresh)
│ ├─ For each section in definition:
│ │ ├─ GET /mobile/screens/{id}/data/{sectionId}
│ │ └─ sectionRegistry.get(type) → React component
│ └─ Action handler (navigate, api-call, copy, share, etc.)
│
└─ Not found? → TenantModulePage (tenant-uploaded JS bundles)
File Structure
frontend/lib/sdui/
├── screen-renderer.tsx # Core renderer — fetches definition, renders header + sections
├── section-registry.ts # Maps type strings to React components
├── action-handler.ts # Executes SDUI actions (navigate, API call, copy, etc.)
├── field-renderer.tsx # Renders data fields (text, badge, currency, date, etc.)
├── types.ts # TypeScript types (shared schema with mobile)
└── sections/
├── stats-row.tsx # Stat cards in responsive grid
├── list-section.tsx # Paginated list with search, filters, tap actions
├── grid-section.tsx # Card grid with configurable columns
├── detail-fields.tsx # Key-value rows in a card
├── form-section.tsx # Dynamic input form with validation
├── card-section.tsx # Single content card
├── chart-section.tsx # Bar/line/pie charts
├── timeline-section.tsx # Status timeline with dots and lines
├── actions-bar.tsx # Horizontal button row
├── empty-state.tsx # Empty placeholder with icon
├── banner-section.tsx # Alert banner (info/warning/error/success)
├── tabs-section.tsx # Tab switcher with nested SDUI sections
└── image-gallery.tsx # Horizontal image carousel
The catch-all route lives at:
frontend/app/m/[...slug]/page.tsx
Section Components
Each web section component receives the same JSON node as the mobile equivalent but renders using shadcn/ui primitives.
| Type | Component | shadcn/UI Dependencies | Description |
|---|---|---|---|
stats-row |
StatsRowSection |
Card | Responsive grid (2 cols on mobile, 4 on desktop), each stat in a Card |
list |
ListSection |
Card, Input, Badge, Button | Search bar, horizontally scrollable filter chips, paginated card rows, tap actions |
grid |
GridSection |
Card | Responsive card grid, column count from columns prop |
detail-fields |
DetailFieldsSection |
Card, Badge | Key-value rows with formatted fields inside a Card |
form |
FormSection |
Card, Input, Select, Switch, Button | Dynamic form built from inputs[], validation, submit action |
card |
CardSection |
Card | Single content card with optional icon |
chart |
ChartSection |
Card | Bar, line, pie, or donut chart inside a Card |
timeline |
TimelineSection |
Card | Vertical timeline with colored dots and connecting lines |
actions-bar |
ActionsBarSection |
Button | Horizontal row of buttons with variant support (default, destructive, outline, ghost) |
empty-state |
EmptyStateSection |
— | Centered icon + message, no external dependencies |
banner |
BannerSection |
Alert | Info/warning/error/success banners using Alert component |
tabs |
TabsSection |
Tabs | Tab switcher; each tab contains nested SDUI sections (recursive rendering) |
image-gallery |
ImageGallerySection |
— | Horizontal scrolling image carousel |
The mobile-only camera-scanner type is not registered on web. If the backend includes it, the web renderer gracefully skips it (shows a debug outline in development mode).
Differences from Mobile SDUI
The backend JSON schema is identical. Differences are in rendering and platform capabilities:
| Aspect | Web | Mobile |
|---|---|---|
| Component library | shadcn/ui + Tailwind CSS | Custom RN components |
| Section count | 13 (no camera-scanner) | 14 |
| Screen ID format | Slash-separated (products/list) |
Dot-separated (products.list) |
| Navigation | Next.js router.push() |
Expo Router router.push() |
| Modals/Sheets | Navigates to /m/sdui/{screenId} |
Native Modal / BottomSheet overlay |
| Pull-to-refresh | Refresh button in header | Native pull-to-refresh gesture |
| Clipboard | navigator.clipboard API |
Expo Clipboard |
| Share | Web Share API (fallback: copy) | Native share dialog |
| Caching | No client-side cache (relies on backend) | Two-layer cache (memory + AsyncStorage) |
| Offline | Not supported | Cached definitions as fallback |
| Layout | ThemedLayout wrapper |
Stack navigator |
Action Handler
The web action handler at frontend/lib/sdui/action-handler.ts supports all 11 action types:
| Action | Web Behavior |
|---|---|
navigate |
router.push(route) with interpolated params as query string |
api-call |
Calls backend via API client, optional confirmation dialog (window.confirm) |
back |
router.back() |
refresh |
Dispatches sdui:refresh-screen CustomEvent |
copy |
navigator.clipboard.writeText() with textarea fallback |
share |
Web Share API if available, otherwise copies to clipboard |
call |
Opens tel: link |
email |
Opens mailto: link |
external-url |
window.open() in new tab |
open-modal |
Navigates to /m/sdui/{screenId} (full-page on web) |
open-sheet |
Same as open-modal on web |
Template interpolation ({{key}} placeholders) works identically to mobile.
Catch-All Route Resolution
The /m/[...slug] route at frontend/app/m/[...slug]/page.tsx uses a two-phase resolution:
// Phase 1: Check for explicit SDUI prefix
if (slug[0] === 'sdui') {
// /m/sdui/orders-list → screenId = "orders-list"
render ScreenRenderer
}
// Phase 2: Check if an SDUI screen exists for the full slug
const screenId = slug.join('/'); // /m/products/list → "products/list"
const result = await api.get(`/mobile/screens/${screenId}`);
if (result.data) {
render ScreenRenderer // SDUI screen found
} else {
render TenantModulePage // Fall back to tenant module loader
}
This means:
/m/products/list— checks SDUI first, renders product list screen if the Products module has a screen provider/m/sdui/custom-screen— always treated as SDUI (explicit prefix)/m/my-custom-module/dashboard— checks SDUI, falls back to tenant-uploaded module if no screen exists
Testing SDUI Screens on Web
After registering a screen provider and running php artisan module:sync:
- Start the backend:
php artisan serve - Start the frontend:
npm run dev(fromfrontend/) - Log in and navigate to
/m/{module}/{page}(e.g./m/products/list) - The catch-all route will fetch the SDUI definition and render it
In development mode, unknown section types display a dashed amber outline with the type name for debugging.
No Frontend Rebuild for New Modules
Because the web renderer fetches definitions from the backend API at runtime:
- Write the PHP screen provider
- Register in
module.json - Run
php artisan module:sync - The screen is immediately available at
/m/{module}/{page}
In Docker production, only the backend image needs rebuilding. See Docker deployment — SDUI modules for the workflow.
Related Documentation
- SDUI System Overview — full schema reference, section types, action types, caching
- Creating SDUI Screens — step-by-step guide for module developers
- Mobile App Module — mobile-specific features (push, voice, settings)
- Docker Deployment — production deployment with SDUI module workflow