← Back to Blog

April 3, 2026 · 14 min read

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.

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:

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:

  1. 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.
  2. 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.
  3. Composability — Adding a new section type means writing one fragment and one composable. The screen query automatically includes it via the union's __typename dispatch.

✅ 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:

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:

💡 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

When GraphQL Wins

⚠️ 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:

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:

  1. Separate UI types from domain types — Don't pollute your business schema with presentation fields.
  2. Use unions for section polymorphism — Let the type system enforce what component types exist.
  3. Co-locate fragments with components — One component, one fragment, one source of truth for data requirements.
  4. Send client capabilities with every query — Let the server tailor responses to what the client can render.
  5. Use @defer for 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