Server-Driven UI framework for Cars24. The server sends JSON; the client renders the page. Change the JSON → the page changes on every user's device, no app release.
Why: The home screen is the most component-dense page in the app. It has 8 visually distinct section types, two horizontal rails, two vertical grids, interactive chip tabs, tappable cards with navigation intents, and a bottom-sheet trigger — all of which stress-test every architectural concern simultaneously. A trivially simple screen would hide the gaps in the system.
# Install dependencies
npm install
# iOS (requires Xcode + CocoaPods)
cd ios && pod install && cd ..
npx react-native run-ios
# Android
npx react-native run-androidThe app opens the SDUI-driven home screen by default.
To see the static baseline screen, change initialRouteName in RootNavigator.tsx to "StaticHome".
src/
├── sdui/
│ ├── engine/
│ │ ├── ComponentRegistry.ts # Map<type, entry> — never returns null
│ │ ├── ActionDispatcher.ts # Map<type, handler> + ActionContext injection
│ │ ├── SchemaValidator.ts # Zod envelope validation
│ │ └── SDUIRenderer.tsx # Recursive memo renderer
│ ├── components/
│ │ ├── layouts/ # HorizontalRail, TwoColumnGrid, SectionHeader, SectionContainer
│ │ ├── sections/ # AppHeader, SearchBar, CategoryChips, ManageVehicleSection, PromoBanner
│ │ ├── cards/ # BuyCarCard, SellCarCard, LoanCard, ServiceCard, UsedCarCard, ManageVehicleCard
│ │ └── UnknownComponent.tsx # Graceful fallback (dev=visible, prod=null)
│ ├── actions/ # NavigateAction, BottomSheetAction, ChipSelectAction, OpenUrlAction, ToastAction
│ ├── hooks/ # useSDUIPayload, useActionDispatch
│ ├── store/ # sdui.store.ts (Zustand)
│ ├── theme/ # tokens.ts (Colors, Spacing, Radius, FontSize)
│ ├── types/ # SDUISchema.ts, Actions.ts, ComponentProps.ts
│ └── bootstrap.ts # Registers all components + actions (called once at app start)
├── screens/
│ ├── SDUIScreen.tsx # SDUI-driven screen with TTR/TTI logging
│ └── StaticHomeScreen.tsx # Hardcoded baseline for benchmarking
├── navigation/
│ └── RootNavigator.tsx # React Navigation + ActionContext injection
└── data/
└── home_screen.json # Mock server payload
Every SDUIComponent has this shape:
{
"id": "stable_unique_id",
"type": "registered_component_type",
"props": { "...component-specific data..." },
"children": [ "...nested SDUIComponents..." ],
"action": { "type": "navigate", "payload": { "screen": "CarDetail" } },
"visibility": { "condition": "logged_in" },
"minClientVersion": "1.0",
"maxClientVersion": "2.0"
}Key decisions:
propsisRecord<string, unknown>at the schema level — each component's Zod schema validates and narrows at the boundary. The renderer stays generic.childrenenables arbitrary nesting. Layout components (horizontal_rail,two_column_grid) consume_childComponentsinjected by the renderer.actionis a discriminated union withtype+payload. New interaction types are new action variants, never new props on components.visibilityis evaluated client-side so the server can send conditional content without multiple endpoints.minClientVersion/maxClientVersionenable gradual rollouts — old clients skip unknown components gracefully.
Three-layer approach:
-
schemaVersionon every payload (e.g."1.0"). The client validates this first. Unsupported versions log a warning but don't fail — unknown versions may still render fine with additive changes. -
minClientVersion/maxClientVersionon individual components. The renderer skips (does not crash) components outside the client's version range. This enables gradual component rollouts:- Add a new component type on the server with
minClientVersion: "2.0" - Old clients (v1.x) skip it and render nothing
- New clients (v2.x) render it
- Add a new component type on the server with
-
Additive-only schema changes as the server contract:
- Adding fields: old clients ignore them (Zod strips unknown fields by default)
- Removing fields: new clients default them (Zod
.optional().default(...)) - Changing field types: requires a new
typestring, not a mutation of an existing one
Unknown component fallback: The ComponentRegistry.resolve() function NEVER returns null. Unknown types render UnknownComponent (invisible in production, visible dev-only box in development). This is the crash-safety guarantee.
16 registered types at launch:
| Type | Category |
|---|---|
horizontal_rail |
Layout |
two_column_grid |
Layout |
section_header |
Layout |
section_container |
Layout |
spacer |
Primitive |
app_header |
Section |
search_bar |
Section |
category_chips |
Section (interactive) |
manage_vehicle_section |
Section |
promo_banner |
Section |
buy_car_card |
Card |
sell_car_card |
Card |
loan_card |
Card |
service_card |
Card |
used_car_card |
Card |
manage_vehicle_card |
Card |
5 registered action handlers:
| Type | Effect |
|---|---|
navigate |
Pushes a screen onto the navigation stack |
chip_select |
Updates selected chip in Zustand store |
open_bottom_sheet |
Opens a registered bottom sheet (Alert in demo) |
open_url |
Opens URL in system browser or in-app WebView |
toast |
Shows an in-app toast message |
| Decision | Upside | Cost |
|---|---|---|
| Zod at envelope + component boundary | Runtime safety, typed components | ~15KB bundle, small parse cost |
| Zustand over Context | Zero unnecessary re-renders | One more dependency |
| Recursive renderer with memo | Handles arbitrary nesting | Stack depth limit needed for pathological payloads |
| Additive-only schema | Old clients never break | Schema debt accumulates; needs periodic cleanup |
| FlashList over FlatList | Significantly better scroll perf | ~20KB extra |
| ScrollView for main page scroll | Simpler, avoids nested FlatList issues | Not windowed — full page renders upfront |
What I'd do in production but cut for timebox:
- Replace Alert-based bottom sheet with
@gorhom/bottom-sheet - Wire chip selection to filter visible sections via
visibilityrules - Add error boundary around
SDUIPageRenderer - Add
react-native-reanimatedfor smooth chip selection transitions - Add schema migration layer for breaking changes
| File | Description |
|---|---|
README.md |
Setup, architecture, schema design, versioning, trade-offs |
PERF.md |
Static vs SDUI benchmark (Android emulator, debug build) |
COVERAGE.md |
Component registry, coverage claim, extension strategy |
AI_WORKFLOW.md |
AI tool stack, prompt stories, failure case, verification |
src/data/home_screen.json |
Mock server payload driving the home screen |
| Screen recording | 3–5 min demo (JSON render, interactions, unknown fallback, live JSON edit) |