nof0xgiven

SDUI

A schema-driven UI architecture where JSON schemas define screens, a single renderer paints them, and AI can compose new interfaces at runtime — 62+ screens from 3 catch-all routes.

What is SDUI?

Every SaaS product eventually drowns in its own UI code. Feature one gets a page. Feature two gets a page. Feature forty-seven gets a page. Each page has its own data fetching, its own layout logic, its own conditionals, its own special cases. By the time you have sixty screens, you have sixty separate implementations of roughly the same idea: fetch some data, arrange some components, handle some events.

SDUI (Schema-Driven UI) inverts this. Instead of writing screens, you write schemas — declarative JSON documents that describe what a screen looks like, what data it needs, and what happens when the user interacts with it. A single, deliberately dumb renderer reads the schema and paints whatever it says. The renderer doesn't know what screen it's rendering. It doesn't care. It just follows instructions.

The result: a multi-tenant B2B2C platform serving 62+ screens across 15 feature domains, powered by exactly 3 catch-all route files. No per-screen routes. No per-screen components. No per-screen data fetching logic. One renderer. One component registry. One expression grammar. Schemas are the UI.

The one-sentence summary

A dumb renderer that faithfully paints whatever schema it is given — whether written by humans or composed by AI.

How It Works

Incoming Request/coach/clients/123/nutritionTenant Resolutionsubdomain → DB · before app runtimeCatch-all Route/coach/$ · /client/$ · /ai/$Schema Resolutionpath → schema ID (exact match first)parameterized fallback: /clients/[id]/nutritionfetch from CDN · validate with AJVSchema SourcesDefault (bundled, CDN)AI-composed (runtime)Saved (coach reuse)all follow same specData Loaderschema declares data requirements · dependency analysisparallel execution within layers · tenant-scoped cachingTenant API277 endpoints · isolated DBSchema RendererExpressionEvaluatorbind · showIf · eachComponentRegistrylookup by key · slotsHandlerRegistrynavigate · submit · APIValidatorAJV · type checksReact Elements → Screen

The Traditional Way vs. SDUI

TRADITIONAL (per-screen code):                SDUI (schema-driven):

Screen 1:  route + page + data + layout       Screen 1:  schema.json
Screen 2:  route + page + data + layout       Screen 2:  schema.json
Screen 10: route + page + data + layout       Screen 10: schema.json
Screen 30: route + page + data + layout       Screen 30: schema.json
Screen 62: route + page + data + layout       Screen 62: schema.json

62 routes, 62 pages, 62 data fetchers         3 catch-all routes, 1 renderer
Shared state: good luck                       Shared state: data loader handles it
New screen: write code, deploy                New screen: write JSON, publish to CDN
AI generates UI: impossible                   AI generates UI: it writes a schema

The Schema

A schema is a complete description of a screen. It declares identity, data requirements, and a component tree:

{
  "id": "coach/clients/[id]/nutrition",
  "version": 2,
  "meta": {
    "module": "nutrition",
    "title": "i18n:nutrition.title"
  },
  "data": {
    "plan": {
      "source": "api",
      "endpoint": "/nutrition/plans/:planId",
      "params": { "planId": "route.params.id" }
    },
    "meals": {
      "source": "api",
      "endpoint": "/nutrition/plans/:planId/meals",
      "params": { "planId": "route.params.id" }
    }
  },
  "root": {
    "component": "page-layout",
    "slots": {
      "default": [
        {
          "component": "section-header",
          "bind": { "title": "plan.name" }
        },
        {
          "component": "stack",
          "slots": {
            "default": [
              {
                "each": "meals",
                "as": "meal",
                "component": "meal-card",
                "bind": {
                  "name": "meal.name",
                  "kcal": "meal.totalKcal"
                }
              }
            ]
          }
        }
      ]
    }
  }
}

The schema declares everything. The renderer knows nothing about nutrition, clients, or meals. It sees components, slots, bindings, and data keys.

{
  "component": "food-row",
  "bind": {
    "name": "food.name || 'Unknown'",
    "kcal": "food.kcal ?? 0",
    "editable": "user.role === 'coach'"
  }
}

Expressions use a restricted grammar — property access, comparisons, boolean logic, ternaries, nullish coalescing. No function calls. No assignment. No arithmetic. Declarative only.

Reserved identifiers provide context: route (path, params, search), user, tenant, session, meta (loading/error state), $index, $first, $last for loops.

{
  "component": "empty-state",
  "props": { "message": "No foods added yet" },
  "showIf": "meal.foods.length === 0"
}
{
  "each": "meal.foods",
  "as": "food",
  "key": "food.id",
  "component": "food-row",
  "bind": { "name": "food.name" }
}

showIf takes any expression. each iterates arrays. as names the loop variable. The renderer handles both uniformly — no special conditional components, no map helpers.

{
  "component": "message-composer",
  "on": {
    "submit": {
      "handler": "sendMessage",
      "params": {
        "conversationId": "route.params.id",
        "content": "event.content"
      }
    }
  }
}

Event handlers are registered in a handler registry. The schema references them by name. Parameters are expressions evaluated at invocation time. event carries the payload from the component.

Schema Resolution

The resolution algorithm is deterministic. No ambiguity, no fallback guessing:

Path becomes candidate

The URL path is split into segments. /coach/clients/123/nutrition becomes the candidate schema ID coach/clients/123/nutrition.

Exact match first

If a schema exists with that exact ID (bundled or CDN), use it. Done.

Parameterized fallback

If no exact match, generate candidates by replacing segments with [id] from right to left. Pick the most specific match — fewest [id] segments, and where ties exist, prefer [id] furthest to the right.

coach/clients/123/nutrition     ← exact (preferred)
coach/clients/[id]/nutrition    ← 1 param
coach/clients/[id]/[id]         ← 2 params
coach/[id]/[id]/[id]            ← 3 params (least preferred)

Param extraction

Each [id] segment maps left-to-right to route.params.id, route.params.id2, route.params.id3. Schemas bind to these via expressions. The renderer never interprets them — it just resolves the values.

The Data Loader

Schemas declare what data they need. The data loader figures out how to get it.

Each schema has a data block where keys map to requirements — an API endpoint, a route param, or a local value. The loader analyses dependencies between requirements (requirement B needs the result of requirement A), arranges them into execution layers, and runs each layer in parallel.

Layer 0: [client, tenant_config]     ← no dependencies, run in parallel
Layer 1: [plan, preferences]         ← depend on client, run in parallel
Layer 2: [meals, supplements]        ← depend on plan, run in parallel

Results are cached by tenant + key. The data loader doesn't know what it's loading — it sees requirement shapes and dependency edges. It could be nutrition data, calendar events, or training programs. Same execution path every time.

The Component Registry

Components are the vocabulary. Schemas are the grammar.

The platform ships a single component library — primitives (text, button, input, card, stack) and composites (meal-card, workout-row, calendar-grid). Every component is registered by string key. The renderer looks up the key, resolves props via expression evaluation, fills named slots with child component trees, and renders.

New component = app deploy. New screen using existing components = publish a JSON file to the CDN. This separation means the vocabulary grows slowly (components need engineering) while the grammar evolves fast (schemas are just data).

Extensions add namespaced vocabulary — new components, new schemas, new handlers — but they don't change the grammar. Everything still flows through the same renderer. No plugins. No runtime-loaded bundles. No alternative execution paths.

Why Three Routes

This is the part that makes people uncomfortable.

A platform with 62+ screens, 15 feature domains, and both coach and client interfaces has exactly three route files:

RoutePurpose
/coach/$Catch-all for all coach screens
/client/$Catch-all for all client screens
/ai/$Catch-all for AI-composed interfaces

Each route does the same thing: resolve tenant context, resolve schema ID from the URL path, load the schema, run the data loader, hand the result to the renderer. That's it. No UI logic. No data fetching. No layout composition. No conditionals for "special screens."

The temptation to add a dedicated route for a complex screen is constant. The answer is always no. If the screen needs special behaviour, the schema needs to be more expressive — not the route.

This constraint is what makes AI composition possible. If every screen had its own route with its own logic, an AI couldn't create a new screen without also creating a new route, a new data fetcher, and a new layout. With SDUI, the AI writes a JSON document that follows the same spec as every other screen. The renderer doesn't know the difference between a human-authored schema and an AI-composed one. It shouldn't.

Multi-Tenancy

The platform is B2B2C — businesses subscribe, each gets a tenant, each tenant serves end users. Tenant isolation is non-negotiable:

  • One subdomain, one tenant. {tenant}.platform.io resolves to a dedicated database before the application runtime even starts. No in-app tenant switching. No /t/:tenant routing.
  • Per-tenant databases. Every tenant has its own Postgres database. No cross-tenant reads or writes. No shared mutable state.
  • Entitlement-gated modules. The module system filters available screens and navigation items by tenant configuration. A tenant that hasn't enabled nutrition never sees nutrition schemas, components, or API endpoints.

Schemas are tenant-agnostic — the same schema works for every tenant. Data is tenant-specific — the data loader always operates within the resolved tenant's database context. This separation means one schema definition serves thousands of tenants without per-tenant forks.

What Makes This Different

62+ screens, 3 routes

No per-screen routing. No per-screen data fetching. Catch-all routes delegate everything to schema resolution and the renderer. Adding a screen means publishing JSON, not writing code.

AI-native by architecture

If the UI is just a JSON document following a spec, AI can write that document. The renderer doesn't distinguish human-authored schemas from AI-composed ones. Same spec, same validation, same rendering path.

Vocabulary grows slow, grammar grows fast

New components require engineering and deployment. New screens require a JSON file. The expensive part (components) changes rarely. The cheap part (screen composition) changes constantly.

The renderer is deliberately dumb

It doesn't know what screen it's rendering. It doesn't branch on domain. It evaluates expressions, resolves components from a registry, fills slots, and paints. That's the entire job description.

Scale

The architecture currently supports:

MetricCount
Screens62+
Feature domains15
Database tables76 (down from 138 in v1, via JSONB consolidation)
API endpoints277
Route files3
Renderer implementations1

The previous version of the platform had 138 database tables and per-screen routing. The rewrite consolidated tables through JSONB columns and unified type-discriminated tables, then eliminated per-screen routes entirely. The schema-driven approach didn't just reduce code — it changed the economics of building screens from "engineering effort" to "data entry."

The Expression Grammar

The expression system is intentionally restrictive. It supports enough to be useful and forbids everything that would make schemas unpredictable:

AllowedExample
Property accessmeal.name, foods[0]
Comparisons===, !==, >, <, >=, <=
Boolean logic&&, ||, !
Ternarycondition ? valueA : valueB
Nullish coalescingvalue ?? 'default'
Literalstrue, false, null, 0, "string"
ForbiddenWhy
Function calls foo()Security, keeps it declarative
Assignment =, +=Side effects
Arithmetic +, -, *Complexity creep
Object/array literalsComplexity
new, typeof, instanceofSecurity

The grammar is small enough that an AI model can learn it completely from a few examples. This is by design — a complex expression language would make AI composition unreliable. A restricted one makes it predictable.

Tech Stack

ComponentTechnology
Monorepopnpm workspaces
Tenant appTanStack Start + React 19 (SSR)
APIHono
DatabasePostgres + Drizzle ORM (per-tenant isolation)
AuthBetter Auth (tenant-local)
Schema validationAJV
Schema deliveryCDN (read-only at runtime)
TestingGraph-based integration tests against real Postgres (PTF)

The Core Insight

The UI layer of most applications is a pile of special cases pretending to be a system. Every screen is hand-built. Every data flow is hand-wired. Every layout is hand-composed. When you want to change something, you change code. When you want AI to help, it has to understand the entire codebase — every route, every component tree, every data dependency — to make a meaningful contribution.

SDUI replaces the pile of special cases with a single, uniform abstraction: schemas in, UI out. The renderer is a function. Schemas are its arguments. The function doesn't change. The arguments do. This means humans and AI operate through the same interface, with the same constraints, producing the same artefacts. Not "AI-assisted development." AI and humans writing the same JSON, validated by the same rules, rendered by the same code.

The renderer is dumb. That's the whole point. Intelligence lives in the schemas, not the infrastructure. And schemas are just data.

On this page