GraphQL + SDUI: The Architecture Guide
GraphQL wasn't designed for server-driven UI — but its type system, union types, and fragment model make it arguably the best transport layer for it. Here's how to design the schema, structure your queries, and learn from teams like Airbnb and DoorDash who've done it at scale.
In This Article
Why GraphQL Is a Natural Fit for SDUI
If you've been following the server-driven UI space, you've probably noticed a pattern: the most sophisticated SDUI implementations — Airbnb, DoorDash, Shopify — all run on GraphQL. That's not a coincidence.
SDUI has a fundamental problem: the server needs to send a tree of heterogeneous UI components, where each component has different data requirements. A hero banner needs an image URL and a headline. A product carousel needs a list of products with prices, ratings, and thumbnails. A review section needs star counts, user names, and text bodies.
With REST, you either over-fetch (send everything every component might need) or build a bespoke endpoint per screen. GraphQL solves this structurally:
- Typed schema — Every component's data contract is defined in SDL (Schema Definition Language). The server and client agree on types at build time, not at runtime when something crashes.
- Union types and interfaces — A screen section can be a
HeroBannerOR aProductCarouselOR aReviewList. GraphQL's union types model this polymorphism natively. No"type": "hero"string matching and hoping for the best. - Fragments — Each client component declares exactly what fields it needs via a co-located fragment. The query assembles these fragments into a single request. No over-fetching. No under-fetching. Each component gets precisely its data.
- Introspection — Clients can discover what component types the server supports at build time. Tooling can validate that every component type in your registry has a matching fragment.
Let's look at what this means in practice.
Schema Design Patterns
The first decision when designing a GraphQL schema for SDUI is: do you mix UI types with domain types, or keep them separate?
Separating UI Types from Domain Types
Apollo GraphQL's official SDUI guide recommends keeping UI types in a separate layer from your domain types. This is the pattern that scales.
Your domain schema already has types like Product, User, Order. These represent your business data. Your UI schema adds a layer on top: HeroSection, CarouselSection, ScreenLayout. These represent how the data is presented.
# Domain types — your business data
type Product {
id: ID!
name: String!
price: Float!
rating: Float
imageUrl: String!
category: String!
}
type Review {
id: ID!
author: String!
stars: Int!
body: String!
date: String!
}
# UI types — how data is presented
type HeroSection {
title: String!
subtitle: String
imageUrl: String!
action: UIAction
}
type ProductCarouselSection {
title: String!
products: [Product!]!
layout: CarouselLayout!
}
type ReviewListSection {
title: String!
reviews: [Review!]!
showRatingSummary: Boolean!
}
type BannerSection {
text: String!
backgroundColor: String!
dismissible: Boolean!
action: UIAction
}
Notice the boundary: domain types (Product, Review) hold business data. UI types (HeroSection, ProductCarouselSection) hold presentation decisions — what title to show, which layout to use, whether a rating summary is visible. The server makes these decisions, not the client.
Union Types for Component Polymorphism
The real power of GraphQL for SDUI is union types. A screen is a list of sections, and each section can be any component type:
# The key abstraction: a section can be any component type
union Section =
HeroSection
| ProductCarouselSection
| ReviewListSection
| BannerSection
| CategoryGridSection
| TextSection
| ImageSection
| PromoCardSection
type Screen {
id: ID!
title: String!
sections: [Section!]!
}
type UIAction {
type: UIActionType!
destination: String
params: [KeyValue!]
}
enum UIActionType {
NAVIGATE
DEEPLINK
DISMISS
HTTP_REQUEST
}
type Query {
screen(route: String!): Screen
}
This is fundamentally different from the REST approach where you'd send { "type": "hero", "data": {...} } and parse a string. With GraphQL unions, the type system enforces that each section is a known type. Your codegen tools generate exhaustive when / switch statements. If you add a new section type, the compiler tells you everywhere that needs updating.
💡 Interface vs Union
Use an interface when all section types share common fields (like id and title). Use a union when section types have nothing in common. In practice, most SDUI schemas start with unions and evolve to interfaces as common patterns emerge. Apollo's docs recommend interfaces with a shared id field for cachability.
Actions as First-Class Schema Types
One pattern that separates mature SDUI schemas from toy examples: actions are typed, not strings. Every interactive component carries a structured action type:
interface UIAction {
actionType: UIActionType!
}
type NavigateAction implements UIAction {
actionType: UIActionType!
route: String!
params: [KeyValue!]
transition: TransitionType
}
type DeeplinkAction implements UIAction {
actionType: UIActionType!
url: String!
fallbackRoute: String
}
type HttpAction implements UIAction {
actionType: UIActionType!
url: String!
method: HttpMethod!
onSuccess: UIAction
onError: UIAction
}
enum TransitionType {
PUSH
MODAL
REPLACE
}
With typed actions, your client gets compile-time guarantees about what fields each action type carries. No more guessing whether a navigate action has a params field or not.
The Fragment-Per-Component Pattern
This is arguably the most important pattern in GraphQL-powered SDUI. The idea: every native UI component on the client has a co-located GraphQL fragment that declares exactly what data it needs.
When you build a HeroBanner composable in Kotlin, you also write its fragment:
// HeroBanner.graphql — co-located with HeroBanner.kt
fragment HeroBannerFragment on HeroSection {
title
subtitle
imageUrl
action {
actionType
... on NavigateAction {
route
transition
}
}
}
// ProductCarousel.graphql
fragment ProductCarouselFragment on ProductCarouselSection {
title
layout
products {
id
name
price
imageUrl
rating
}
}
// ReviewList.graphql
fragment ReviewListFragment on ReviewListSection {
title
showRatingSummary
reviews {
id
author
stars
body
}
}
Then the screen query composes these fragments:
query HomeScreen($route: String!) {
screen(route: $route) {
id
title
sections {
__typename
...HeroBannerFragment
...ProductCarouselFragment
...ReviewListFragment
...BannerSectionFragment
}
}
}
This pattern has three major wins:
- No over-fetching — Each component only requests the fields it renders. The hero banner doesn't fetch product prices. The review list doesn't fetch image URLs it won't use.
- Co-location — The data contract lives next to the rendering code. When you change what a component displays, you update the fragment in the same PR. No separate "API contract" file to keep in sync.
- Composability — Adding a new section type means writing one fragment and one composable. The screen query automatically includes it via the union's
__typenamedispatch.
✅ The Co-location Rule
If a component renders a field, its fragment declares that field. If a component doesn't render a field, the fragment doesn't include it. This 1:1 mapping makes it trivial to audit data usage and eliminates ghost fields that nobody knows if they're still needed.
Client-Side Rendering with Fragments
Here's how the pattern looks end-to-end in Kotlin with Apollo Client:
// Kotlin — Fragment-driven rendering
@Composable
fun SDUIScreen(route: String) {
val response = apolloClient
.query(HomeScreenQuery(route))
.watch()
.collectAsState()
val screen = response.value?.data?.screen ?: return
LazyColumn {
items(screen.sections) { section ->
when (section.__typename) {
"HeroSection" -> {
HeroBanner(section.heroBannerFragment!!)
}
"ProductCarouselSection" -> {
ProductCarousel(section.productCarouselFragment!!)
}
"ReviewListSection" -> {
ReviewList(section.reviewListFragment!!)
}
"BannerSection" -> {
Banner(section.bannerSectionFragment!!)
}
else -> {
// Unknown section type — skip or show placeholder
Spacer(modifier = Modifier.height(0.dp))
}
}
}
}
}
// Each component takes its fragment type — fully type-safe
@Composable
fun HeroBanner(data: HeroBannerFragment) {
Box(modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = data.imageUrl,
contentDescription = data.title
)
Column(modifier = Modifier.padding(24.dp)) {
Text(
text = data.title,
style = MaterialTheme.typography.headlineLarge
)
data.subtitle?.let {
Text(text = it, style = MaterialTheme.typography.bodyLarge)
}
}
}
}
Every component receives a generated type from its fragment. No JsonObject parsing. No getString("title") calls that might return null. The compiler enforces the contract.
Real Examples: Airbnb and DoorDash
Let's look at how two of the most mature SDUI implementations use GraphQL in production.
Airbnb: Sections as Unions
Airbnb's server-driven UI system powers most of the screens you see in their app — search results, listing details, the explore page. Their GraphQL schema models screens as ordered lists of typed sections.
Conceptually, their schema looks like this:
# Airbnb's approach (simplified from public talks)
union ExploreSection =
HeroExploreSection
| CategoryBarSection
| ListingCarouselSection
| MapSection
| ExperienceRowSection
| InlinePromotionSection
type ExplorePage {
sections: [ExploreSection!]!
pagination: PaginationInfo
}
type ListingCarouselSection {
title: String!
subtitle: String
listings: [ListingSummary!]!
seeAllAction: NavigateAction
}
type ListingSummary {
id: ID!
name: String!
pricePerNight: Money!
rating: Float
reviewCount: Int!
images: [String!]!
superhost: Boolean!
}
Key design decisions from Airbnb:
- Sections own their data — A
ListingCarouselSectioncontainsListingSummaryobjects, not IDs that require a separate lookup. This enables single-request rendering. - Typed actions everywhere — The
seeAllActionon a carousel isn't a string URL. It's aNavigateActionwith a route and transition type. - Server decides ordering — The backend returns sections in the order they should appear. The client renders them sequentially. Personalization, A/B testing, and geo-targeting all happen server-side.
- Fragment co-location — Each section component in the iOS and Android apps has a co-located fragment. Adding a new section type means adding one schema type, one fragment, and one native component.
DoorDash: Presentation Layer Separation
DoorDash takes the separation of concerns further. Their GraphQL schema has an explicit presentation layer that wraps domain types in UI-aware containers:
# DoorDash's approach (derived from public engineering blog posts)
# Domain type — pure business data
type Store {
id: ID!
name: String!
cuisineType: String!
deliveryFee: Money!
estimatedDeliveryMinutes: Int!
rating: Float!
}
# Presentation wrapper — UI decisions on top of domain data
type StoreCardPresentation {
store: Store!
badgeText: String # "Free Delivery" | "30% Off" | null
badgeColor: String # hex color, server-decided
promotionLabel: String # "Sponsored" | null
layout: CardLayout! # COMPACT | WIDE | FEATURED
impressionTrackingId: ID!
}
type StoreFeedSection {
title: String!
storeCards: [StoreCardPresentation!]!
scrollDirection: ScrollDirection!
}
enum CardLayout {
COMPACT
WIDE
FEATURED
}
enum ScrollDirection {
HORIZONTAL
VERTICAL
}
The key insight: StoreCardPresentation wraps a Store and adds UI-specific decisions — badge text, badge color, card layout — that the server controls. The client never decides whether to show a "Free Delivery" badge. The server sends it (or doesn't), and the client renders accordingly.
This means DoorDash can:
- A/B test badge colors without a client release
- Change card layouts per market (compact in dense cities, wide in suburbs)
- Swap promotion labels instantly during flash sales
- Track impressions per-card with server-assigned IDs
💡 The Presentation Layer Pattern
If you're building SDUI on top of an existing GraphQL API, don't pollute your domain types with UI fields. Create wrapper types in a presentation namespace. This keeps your domain schema clean for non-SDUI consumers (web, internal tools) while giving mobile clients the UI metadata they need.
REST vs GraphQL for SDUI
Not every team needs GraphQL for SDUI. Here's an honest comparison to help you decide.
| Factor | REST | GraphQL |
|---|---|---|
| Component polymorphism | String-based "type" field, parsed at runtime |
Union types with compile-time exhaustiveness |
| Data fetching | Server decides payload (often over-fetches) | Client fragments specify exact fields needed |
| Type safety | Requires separate codegen (JSON Schema → types) | Built-in schema + codegen (Apollo, graphql-codegen) |
| Caching | HTTP caching (CDN-friendly, simpler) | Normalized cache (per-object, more powerful) |
| Infrastructure | Any HTTP server works | Requires GraphQL server + schema registry |
| Team familiarity | Everyone knows REST | Learning curve for schema design |
| Partial loading | All-or-nothing per endpoint | @defer can stream sections progressively |
When REST Makes Sense
- Your SDUI system has fewer than ~10 component types
- Layouts are relatively static (same structure, different data)
- You want CDN caching on layout responses
- Your team doesn't have GraphQL experience and your SDUI needs are simple
- You're building a backend-first SDUI system where server engineers own the layout logic
When GraphQL Wins
- You have 20+ component types with different data shapes
- Different screens compose the same component types in different orders
- You need fine-grained data fetching (mobile bandwidth matters)
- You're already running a GraphQL gateway (e.g., Apollo Federation)
- You want
@deferto progressively render sections as data arrives - Type safety across the full stack is a priority
⚠️ Don't Adopt GraphQL Just for SDUI
If your app doesn't already use GraphQL, adding it solely for SDUI is a big investment. A well-designed REST API with JSON Schema validation (or a typed DSL) gives you 80% of the type safety benefits at 20% of the infrastructure cost. Adopt GraphQL for SDUI when you're already invested in the ecosystem.
Where Pyramid Fits
Here's the thing about GraphQL + SDUI: the schema design patterns are well-understood. Apollo has documented them. Airbnb and DoorDash have proven them. The hard part isn't designing the schema — it's implementing the client-side rendering engine that consumes it.
You need a component registry that maps GraphQL types to native views. You need a fallback system for unknown types. You need an action handler that processes NavigateAction, HttpAction, and DeeplinkAction. You need a state management layer that binds GraphQL data to reactive UI. You need versioning, error handling, and experimentation support.
Pyramid provides that implementation layer. It works with both REST and GraphQL backends:
- With GraphQL — Pyramid's typed DSL generates the schema types that match your GraphQL SDL. Your fragments map directly to Pyramid component definitions. The rendering engine handles union type dispatch, fragment data binding, and fallback rendering.
- With REST — Pyramid's DSL generates JSON schemas and typed models. The same component registry and rendering engine works — the transport layer is abstracted away.
The schema design is your architecture. Pyramid is the runtime that makes it work on the device.
// Pyramid DSL — define once, generate for both REST and GraphQL
// This definition generates:
// - GraphQL SDL types
// - Kotlin data classes
// - Swift Codable structs
// - JSON Schema for REST validation
component HeroSection {
title: String
subtitle: String?
imageUrl: String
action: NavigateAction?
}
component ProductCarousel {
title: String
products: [Product]
layout: CarouselLayout = HORIZONTAL
}
screen HomeScreen {
sections: [HeroSection | ProductCarousel | ReviewList | Banner]
}
One definition. Type-safe clients on both platforms. Works whether your backend speaks GraphQL or REST.
Query Patterns in Practice
Let's walk through common query patterns you'll use when building GraphQL-powered SDUI.
Pattern 1: Screen-Level Query
The most common pattern — fetch an entire screen's layout in a single query:
query GetScreen($route: String!, $context: ScreenContext!) {
screen(route: $route, context: $context) {
id
title
sections {
__typename
... on HeroSection {
title
subtitle
imageUrl
action { ...ActionFields }
}
... on ProductCarouselSection {
title
layout
products {
id
name
price
imageUrl
}
}
... on BannerSection {
text
backgroundColor
dismissible
action { ...ActionFields }
}
}
}
}
fragment ActionFields on UIAction {
actionType
... on NavigateAction { route transition }
... on DeeplinkAction { url fallbackRoute }
}
Pattern 2: Deferred Section Loading
Use GraphQL's @defer directive to render the screen shell immediately while heavy sections load in the background:
query HomeScreenDeferred($route: String!) {
screen(route: $route) {
id
title
# Hero loads immediately
heroSection {
title
subtitle
imageUrl
}
# Heavy sections stream in progressively
... @defer {
recommendations {
__typename
... on ProductCarouselSection {
title
products { id name price imageUrl }
}
}
}
... @defer {
recentlyViewed {
__typename
... on ProductCarouselSection {
title
products { id name price imageUrl }
}
}
}
}
}
The user sees the hero banner instantly. Personalized recommendations and recently-viewed items stream in as the server resolves them. No spinners for the whole screen — just progressive rendering.
Pattern 3: Contextual Queries
Pass client context (device type, app version, locale, experiment assignments) to let the server tailor the layout:
query GetScreen($route: String!, $context: ScreenContext!) {
screen(route: $route, context: $context) {
sections { ... }
}
}
# Variables
{
"route": "/home",
"context": {
"platform": "ANDROID",
"appVersion": "4.2.0",
"locale": "en-US",
"supportedComponents": [
"HeroSection",
"ProductCarouselSection",
"ReviewListSection",
"BannerSection",
"CategoryGridSection"
],
"experiments": {
"homepage_v2": "variant_b"
}
}
}
The supportedComponents field is critical for backward compatibility. The server knows which types this client version can render and only sends those. Older clients don't get new component types they can't handle — no fallback needed.
✅ The Capability Handshake
Always send your client's supported component list with every screen query. This lets the server downgrade gracefully for older app versions instead of sending types that trigger fallback rendering. It's the GraphQL equivalent of HTTP content negotiation — and it's how you avoid the "unknown component" problem entirely.
Kotlin Client Setup
Here's a complete Apollo Client setup for SDUI with proper caching and error handling:
// Kotlin — Apollo Client setup for SDUI
class SDUIGraphQLClient(
private val apolloClient: ApolloClient,
private val cache: SDUICache,
private val contextProvider: ScreenContextProvider
) {
suspend fun fetchScreen(route: String): ScreenResult {
val context = contextProvider.currentContext()
return try {
val response = apolloClient
.query(GetScreenQuery(route, context))
.fetchPolicy(FetchPolicy.NetworkFirst)
.execute()
when {
response.data != null -> {
cache.store(route, response.data!!.screen)
ScreenResult.Success(response.data!!.screen)
}
response.hasErrors() -> {
// Try cache on GraphQL errors
cache.get(route)?.let {
ScreenResult.Cached(it)
} ?: ScreenResult.Error(response.errors!!.first().message)
}
else -> ScreenResult.Error("Unknown error")
}
} catch (e: ApolloException) {
// Network failure — fall back to cache
cache.get(route)?.let {
ScreenResult.Cached(it)
} ?: ScreenResult.Error(e.message ?: "Network error")
}
}
}
sealed class ScreenResult {
data class Success(val screen: GetScreenQuery.Screen) : ScreenResult()
data class Cached(val screen: GetScreenQuery.Screen) : ScreenResult()
data class Error(val message: String) : ScreenResult()
}
Wrapping Up
GraphQL and SDUI are a natural pairing. The type system, union types, and fragment model solve SDUI's core problems — component polymorphism, heterogeneous data fetching, and server-client contract enforcement — at the language level rather than through convention.
The key patterns to take away:
- Separate UI types from domain types — Don't pollute your business schema with presentation fields.
- Use unions for section polymorphism — Let the type system enforce what component types exist.
- Co-locate fragments with components — One component, one fragment, one source of truth for data requirements.
- Send client capabilities with every query — Let the server tailor responses to what the client can render.
- Use
@deferfor progressive rendering — Don't make users wait for the entire layout to resolve.
Whether you go with GraphQL or REST with a typed DSL, the architecture principles are the same. Define your contracts formally, generate code from them, and let the server drive the UI while the client renders it natively.
Type-Safe SDUI for Any Backend
Pyramid's typed DSL generates schema contracts for both GraphQL and REST backends. Define your components once — get type-safe Kotlin, Swift, and schema definitions automatically.
Get Early Access →Further Reading
Related Articles
- SDUI Architecture Patterns: Building Scalable Server-Driven UI Systems →
- Why JSON-Based SDUI Breaks at Scale (And What Comes Next) →
- How We Built Typed DSL Codegen for Server-Driven UI →
- Why Airbnb, Lyft, and Netflix Use Server-Driven UI →
- SDUI Tutorial: Build Server-Driven UI with Jetpack Compose →
- SDUI for Backend Developers: What You Need to Know →