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:

  1. Start the backend: php artisan serve
  2. Start the frontend: npm run dev (from frontend/)
  3. Log in and navigate to /m/{module}/{page} (e.g. /m/products/list)
  4. 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:

  1. Write the PHP screen provider
  2. Register in module.json
  3. Run php artisan module:sync
  4. 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