Server‑Driven UI Failures: Build a Safer SwiftUI Renderer for iOS (Month‑Three Survival Guide)
AI assistants increasingly need real native interfaces, not just chat. If you're evaluating the best tools for server-driven UI iOS development or…

AI assistants increasingly need real native interfaces, not just chat. If you're evaluating the best tools for server-driven UI iOS development or experimenting with your own server-driven UI iOS frameworks, this tutorial shows how to build a minimal SwiftUI renderer that avoids the six month‑three failure modes most homegrown systems hit.
We'll go end‑to‑end:
- Define a versioned JSON Schema for your UI contract
- Render it into SwiftUI safely
- Wire actions and state
- Add server‑side validation, moderation, and CI checks
Uzori takes this further as a production‑grade SDK, but this guide helps you understand the architecture and tradeoffs.
Step 1: Define a versioned server‑driven UI JSON Schema (Swift/iOS)
The biggest month‑three failure mode: no typed contract.
Without a schema, your LLM or server starts emitting UI payloads your client doesn't understand, and you get crashes, broken layouts, or silent fallbacks.
1.1 Why JSON Schema and OpenAPI matter
Modern generative systems are converging on structured contracts:
- OpenAI Structured Outputs require models to follow a JSON Schema for safety and reliability (OpenAI docs).
- OpenAPI is the world's most widely used API description standard, designed for client generation, tests, and design consistency (OpenAPI Initiative).
You want the same discipline for UI:
- A versioned JSON Schema for screens
- A fixed catalog of components
- Server‑side validation before any payload hits your app
1.2 Minimal JSON Schema for a server‑driven SwiftUI screen
Below is a machine‑readable JSON Schema for a simple screen contract.
- It uses a
versionfield for evolution. - It defines three components:
text,button,vstack. - Any other component type will be rejected server‑side.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/ui-screen.json",
"title": "UIScreen",
"type": "object",
"required": ["version", "id", "body"],
"properties": {
"version": {
"type": "string",
"enum": ["1.0"]
},
"id": {
"type": "string"
},
"body": {
"type": "array",
"items": { "$ref": "#/definitions/component" }
}
},
"definitions": {
"component": {
"type": "object",
"required": ["type"],
"properties": {
"id": { "type": "string" },
"type": {
"type": "string",
"enum": ["text", "button", "vstack"]
},
"text": { "type": "string" },
"action": { "type": "string" },
"children": {
"type": "array",
"items": { "$ref": "#/definitions/component" }
}
},
"allOf": [
{
"if": { "properties": { "type": { "const": "text" } } },
"then": { "required": ["text"] }
},
{
"if": { "properties": { "type": { "const": "button" } } },
"then": { "required": ["text", "action"] }
},
{
"if": { "properties": { "type": { "const": "vstack" } } },
"then": { "required": ["children"] }
}
]
}
}
}
In your repo, this lives under schemas/ui-screen.json and is the single source of truth for the client and your AI tooling.
Step 2: Handle unknown components in server‑driven UI (SwiftUI)
Month‑three failure mode #2: unknown components crash or render as blank space.
When your schema evolves, older clients encounter new type values. You need explicit handling.
2.1 Swift types for the screen contract
The simplest mapping is to decode into a couple of enums and structs.
Below is a complete, compilable Swift file (Swift 5.9+, Xcode 15+) you can drop into a project.
import SwiftUI
// MARK: - Models
struct UIScreenModel: Decodable, Identifiable {
let version: String
let id: String
let body: [UIComponent]
}
enum UIComponentType: String, Decodable {
case text
case button
case vstack
case unknown
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let raw = try container.decode(String.self)
self = UIComponentType(rawValue: raw) ?? .unknown
}
}
struct UIComponent: Decodable, Identifiable {
let id: String
let type: UIComponentType
let text: String?
let action: String?
let children: [UIComponent]?
enum CodingKeys: String, CodingKey {
case id
case type
case text
case action
case children
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString
self.type = try container.decode(UIComponentType.self, forKey: .type)
self.text = try container.decodeIfPresent(String.self, forKey: .text)
self.action = try container.decodeIfPresent(String.self, forKey: .action)
self.children = try container.decodeIfPresent([UIComponent].self, forKey: .children)
}
}
// MARK: - Action handler protocol
protocol UIActionHandler {
func handle(action: String)
}
// MARK: - Renderer
struct UIScreenView: View {
let screen: UIScreenModel
let actionHandler: UIActionHandler?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(screen.body.indices, id: \ .self) { index in
render(component: screen.body[index])
}
}
.padding()
.navigationTitle("Dynamic Screen")
}
@ViewBuilder
private func render(component: UIComponent) -> some View {
switch component.type {
case .text:
Text(component.text ?? "")
.font(.body)
case .button:
Button(component.text ?? "Button") {
if let action = component.action {
actionHandler?.handle(action: action)
}
}
case .vstack:
VStack(alignment: .leading, spacing: 8) {
if let children = component.children {
ForEach(children.indices, id: \ .self) { index in
render(component: children[index])
}
} else {
UnknownComponentView(reason: "Missing children for vstack")
}
}
case .unknown:
UnknownComponentView(reason: "Unknown type: \(component.type)")
}
}
}
// MARK: - Unknown component fallback
struct UnknownComponentView: View {
let reason: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Unsupported component")
.font(.caption)
.bold()
Text(reason)
.font(.caption)
}
.padding(8)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(.gray.opacity(0.3), lineWidth: 1)
)
.accessibilityLabel("Unsupported component: \(reason)")
}
}
// MARK: - Preview / sample
struct SampleActionHandler: UIActionHandler {
func handle(action: String) {
print("Handling action: \(action)")
}
}
struct UIScreenView_Previews: PreviewProvider {
static var previews: some View {
let json = """
{
"version": "1.0",
"id": "sample",
"body": [
{ "id": "t1", "type": "text", "text": "Welcome" },
{ "id": "b1", "type": "button", "text": "Continue", "action": "continue" },
{
"id": "v1",
"type": "vstack",
"children": [
{ "id": "t2", "type": "text", "text": "Nested" },
{ "id": "x1", "type": "slider", "text": "Unknown component" }
]
}
]
}
"""
let data = Data(json.utf8)
let screen = try! JSONDecoder().decode(UIScreenModel.self, from: data)
return NavigationStack {
UIScreenView(screen: screen, actionHandler: SampleActionHandler())
}
}
}
This example deliberately includes an "type": "slider" component to show how unknown types get routed into UnknownComponentView instead of crashing.
Step 3: Layout discipline in a generated SwiftUI renderer
Month‑three failure mode #3: layout and performance regressions.
Homegrown generated‑UI layers often ignore Apple's guidance and over‑optimize prematurely:
- Apple recommends starting with standard stacks and only using lazy containers if profiling shows benefit (SwiftUI container guidance).
- Custom layout engines often break Dynamic Type, intrinsic sizing, and safe area behavior.
3.1 Keep containers boring and native
In a minimal renderer:
- Use
VStack,HStack,List, andScrollViewdirectly. - Apply padding and alignment consistently.
- Avoid custom layout until you have trace‑backed performance issues.
For example, wrap your body in a ScrollView for long flows:
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ForEach(screen.body.indices, id: \ .self) { index in
render(component: screen.body[index])
}
}
.padding()
}
.navigationTitle("Dynamic Screen")
}
3.2 Basic layout tests
You can add quick layout sanity checks using XCTest + ViewHosting approaches, or just systematic manual tests:
- Verify long text wraps correctly.
- Try Dynamic Type sizes from smallest to largest.
- Rotate between portrait and landscape.
In CI, use snapshot tests to catch accidental layout changes when you evolve the renderer.
Step 4: Accessibility and Dynamic Type for server‑driven UI
Month‑three failure mode #4: accessibility regressions.
Generated layers often bypass native controls, losing:
- VoiceOver labels and actions
- Dynamic Type sizing
- Focus behavior
SwiftUI already provides multiple Dynamic Type sizes, including five accessibility sizes (accessibility1 through accessibility5) (Dynamic Type docs). Use them.
4.1 Accessibility labels from schema
Extend your schema with optional accessibilityLabel and accessibilityHint fields if needed.
Then, in SwiftUI:
@ViewBuilder
private func render(component: UIComponent) -> some View {
switch component.type {
case .text:
Text(component.text ?? "")
.font(.body)
.accessibilityLabel(component.text ?? "")
case .button:
Button(component.text ?? "Button") {
if let action = component.action {
actionHandler?.handle(action: action)
}
}
.accessibilityLabel(component.text ?? "Button")
.accessibilityHint("Activates action \(component.action ?? "")")
case .vstack:
VStack(alignment: .leading, spacing: 8) {
if let children = component.children {
ForEach(children.indices, id: \ .self) { index in
render(component: children[index])
}
} else {
UnknownComponentView(reason: "Missing children for vstack")
}
}
case .unknown:
UnknownComponentView(reason: "Unknown type")
}
}
4.2 Manual accessibility checklist
Before you ship a generated UI layer:
- Turn on VoiceOver and traverse the entire flow.
- Test Dynamic Type at
accessibility4oraccessibility5. - Validate buttons and tappable elements have clear labels and hints.
Uzori leans heavily on native SwiftUI primitives so that accessibility comes for free; your homegrown renderer should do the same.
Step 5: State and action wiring in SwiftUI server‑driven UI
Month‑three failure mode #5: actions and state become unmanageable.
If every button calls handle(action:) with an opaque string, you quickly hit:
- Hard‑to‑debug action routing
- Tight coupling between backend payloads and client logic
- No way to reason about allowed side‑effects
5.1 Define a typed action catalog
Instead of freeform strings, define an enum of allowed actions that matches your backend and/or OpenAPI.
enum UIAction: String {
case continueFlow = "continue"
case cancelFlow = "cancel"
case openTransactionDetails = "open_transaction_details"
}
protocol UIActionHandler {
func handle(action: UIAction)
}
Then map payloads safely:
extension UIAction {
init?(raw: String) {
self.init(rawValue: raw)
}
}
struct TypedActionHandler: UIActionHandler {
func handle(action: UIAction) {
switch action {
case .continueFlow:
// navigate to next step
print("Continuing flow")
case .cancelFlow:
print("Cancelling flow")
case .openTransactionDetails:
print("Opening transaction details")
}
}
}
Update the renderer to use the enum:
case .button:
Button(component.text ?? "Button") {
if let raw = component.action, let action = UIAction(raw: raw) {
actionHandler?.handle(action: action)
} else {
print("Unknown action: \(component.action ?? "nil")")
}
}
5.2 Integrate with OpenAPI‑described backend flows
Your UI actions will typically map to backend operations:
open_transaction_details→ call aGET /transactions/{id}endpointcontinue→ post a step completion in a multi‑step wizard
Because OpenAPI is already your API contract, you can:
- Generate typed clients in Swift.
- Use the same action enum to drive those clients.
This is exactly the pattern Uzori uses, positioned as an AI interface layer between your OpenAPI backend and SwiftUI.
For a deeper example of how a multi‑step flow can be modeled and orchestrated, see the pillar article "Where did my money go? — a transaction dispute is a four-step status answer", which breaks a complex support interaction into four structured states instead of freeform chat.
Step 6: Safety, moderation, and server‑side validation
Month‑three failure mode #6: security and integrity gaps.
Once your generated UI is live, attackers can:
- Try prompt injection against your LLM
- Smuggle unexpected components or actions
- Abuse unmoderated text content
OpenAI's safety best practices emphasize moderation, adversarial testing, human review, and using validated backend materials where possible (OpenAI safety guide). Their agent guidance also recommends structured outputs between nodes to avoid freeform channels attackers can exploit (agent safety).
You should apply the same mindset to your UI layer.
6.1 Server‑side JSON Schema validation
Never let the client render unvalidated payloads.
On your server, run every UIScreenModel through a JSON Schema validator before sending it to the app.
Example using an ajv‑style validator in Node.js:
// server/uiValidator.js
import Ajv from "ajv";
import addFormats from "ajv-formats";
import uiScreenSchema from "./schemas/ui-screen.json" assert { type: "json" };
const ajv = new Ajv({ allErrors: true, strict: true });
addFormats(ajv);
const validateUIScreen = ajv.compile(uiScreenSchema);
export function assertValidUIScreen(payload) {
const valid = validateUIScreen(payload);
if (!valid) {
console.error("Invalid UI payload", validateUIScreen.errors);
throw new Error("Invalid UIScreen payload");
}
}
Use this in your UI generation endpoint:
// server/routes/ui.js
import { assertValidUIScreen } from "../uiValidator.js";
app.post("/ui/generated", async (req, res) => {
const uiPayload = await generateUIPayload(req.body); // LLM + orchestration
assertValidUIScreen(uiPayload); // JSON Schema gate
res.json(uiPayload);
});
6.2 CI gating on schema changes
Schema changes are where month‑three pain really appears.
- Add a CI job that lints and validates
ui-screen.json. - Maintain sample payloads under
fixtures/ui/. - Run
ajvagainst fixtures in CI to catch regressions.
Example CI script:
#!/usr/bin/env bash
set -euo pipefail
node scripts/validate-ui-fixtures.mjs
Where validate-ui-fixtures.mjs loads each JSON fixture and validates it with assertValidUIScreen.
This mirrors what production systems like DivKit do with templates, variables, and fallbacks, and what Uzori does with server‑approved SwiftUI schemas.
6.3 Content moderation and guardrails
For any user‑visible text generated by an LLM:
- Run it through a moderation endpoint (OpenAI, in‑house classifier, etc.).
- Combine LLM output with validated backend data, not arbitrary hallucinations.
- Prefer templates with slots for factual data rather than fully generated paragraphs.
Uzori's point of view here is clear: structure over chaos.
Let AI orchestrate flows and layouts, but keep it inside a constrained, server‑validated contract.
Uzori vs DivKit: best server‑driven UI libraries for iOS
If you're thinking, "should I build this myself or use an SDK?", you're in the right headspace.
DivKit and similar systems focus on server‑driven UI for multiple platforms:
DivViewis uploaded, cached, and integrated in the client.- It supports templates, variables, and fallbacks to reduce parsing size and improve speed (DivKit docs).
Uzori focuses specifically on AI‑generated native SwiftUI for iOS:
- Fully native SwiftUI output, not a generic renderer.
- AI composes screens and flows from a constrained schema.
- Every screen is validated server‑side before streaming into the app.
- Integrates with your existing OpenAPI‑described backend.
In terms of the best tools for server‑driven UI iOS development:
- DivKit is strong if you want a cross‑team SDUI runtime and hand‑authored JSON layouts.
- Uzori is strong if you want an AI UI framework SwiftUI iOS solution where the interface designs itself safely, and you don't want to own all the failure‑mode guardrails described above.
If you build your own, use the patterns in this tutorial to stay closer to Uzori's safety profile than a quick demo.
Putting it all together: an end‑to‑end flow
Here's the lifecycle you should aim for:
- User intent: user asks an AI assistant in your app for help (e.g., "dispute a transaction").
- Backend orchestration:
- LLM interprets intent.
- Backend consults your OpenAPI‑described APIs.
- Backend builds a
UIScreenJSON object.
- Schema validation:
- Run the payload through JSON Schema validation (
ajvor equivalent). - Moderate any user‑visible text.
- Run the payload through JSON Schema validation (
- Client rendering:
- SwiftUI decoder turns JSON into
UIScreenModel. UIScreenViewrenders native controls and handles actions viaUIActionHandler.
- SwiftUI decoder turns JSON into
- State updates:
- Actions call backend APIs.
- Backend returns updated UI payloads.
Uzori's SDK automates most of these steps, but if you're building a homegrown layer, this is your blueprint.
FAQ: Server‑Driven UI, AI UI SDKs, and SwiftUI
What are the best AI UI SDKs for SwiftUI iOS?
Leading options include:
- Uzori iOS SDK (SwiftUI) for AI‑generated native interfaces with server‑validated schemas.
- Traditional SDUI runtimes like DivKit (JSON layouts + templates), which are more manual but widely used.
If you want to generate native iOS UI from LLM responses safely, Uzori is designed specifically for that.
How do I generate native iOS UI from LLM responses?
You need three pieces:
- A JSON Schema that defines allowed components and layouts.
- An LLM prompt that produces JSON adhering to that schema (using tools like OpenAI Structured Outputs).
- A SwiftUI renderer that decodes the JSON and maps components to native views.
Add server‑side validation and moderation to keep the UI safe.
Are AI UI tools safe for large‑scale deployment on iOS?
They can be, if you:
- Use structured outputs with a strict schema.
- Validate every payload server‑side before rendering.
- Moderate user‑visible content.
- Keep actions within a typed catalog and audited backend APIs.
Uzori follows this pattern so teams can ship AI interfaces at scale without losing control.
How does server‑driven UI fit into secure AI iOS app architecture?
Server‑driven UI lets you:
- Keep business logic and sensitive decisions on the server.
- Ship new flows without App Store updates.
- Apply centralized security, logging, and moderation.
Combine it with native SwiftUI rendering and strict schemas to balance flexibility with safety.
Should I build my own server‑driven UI renderer or use Uzori?
Build your own if:
- You have strong in‑house iOS and backend expertise.
- You can invest in schemas, validation, and tooling.
Use Uzori if:
- You want to move faster on AI features.
- You'd rather focus on product flows than SDUI infrastructure.
This tutorial gives you the mental model to evaluate both paths.
By month three, every homegrown generated‑UI layer either gains typed contracts, validation, and native discipline—or starts breaking silently.
Whichever path you choose, keep the contract first, SwiftUI native, and safety non‑negotiable.