• 12 min read

Getting Started with Pyramid: Add Server-Driven UI to Your Android App in 30 Minutes

This tutorial walks you through adding server-driven UI to an existing Jetpack Compose app using Pyramid. By the end, you'll be deploying screen changes from your backend — no app release required.

Prerequisites

What We'll Build

We'll take an existing ProductCard component and make it server-drivable. Then we'll build a home screen from the backend using Pyramid's generated DSL.

Before:

Hardcoded layout, requires app release to change.

After:

Backend-defined layout, deploy in seconds.


Step 1: Add the Pyramid SDK

Add Pyramid's Android SDK to your app's build.gradle.kts:

// build.gradle.kts (app module)
plugins {
    id("com.google.devtools.ksp") version "2.0.0-1.0.22"
}

dependencies {
    implementation("dev.pyramid:sdk:1.0.0")
    ksp("dev.pyramid:ksp-compiler:1.0.0")
}

Sync your project. The KSP compiler will scan for @PyramidBlock annotations at build time.

Step 2: Annotate Your Components

Take any Compose component you want to make server-drivable and add @PyramidBlock:

import dev.pyramid.sdk.annotations.PyramidBlock
import dev.pyramid.sdk.annotations.PyramidProp
import dev.pyramid.sdk.annotations.PyramidEvent

@PyramidBlock(keyType = "PRODUCT_CARD")
@Composable
fun ProductCard(
    @PyramidProp(required = true) title: String,
    @PyramidProp(required = true) imageUrl: String,
    @PyramidProp price: Double = 0.0,
    @PyramidProp badge: String? = null,
    @PyramidEvent onTap: () -> Unit = {},
    @PyramidEvent onAddToCart: () -> Unit = {}
) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .clickable(onClick = onTap)
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            AsyncImage(
                model = imageUrl,
                contentDescription = title,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(text = title, style = MaterialTheme.typography.titleMedium)
            if (badge != null) {
                Badge(text = badge)
            }
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(
                    text = "$${price}",
                    style = MaterialTheme.typography.titleLarge
                )
                IconButton(onClick = onAddToCart) {
                    Icon(Icons.Default.ShoppingCart, "Add to cart")
                }
            }
        }
    }
}

That's it. Your component code doesn't change at all — you're just adding metadata annotations.

What Pyramid Extracts

When you build, KSP generates a schema from your annotations:

{
  "keyType": "PRODUCT_CARD",
  "name": "ProductCard",
  "props": {
    "title": { "type": "STRING", "required": true },
    "imageUrl": { "type": "STRING", "required": true },
    "price": { "type": "FLOAT", "default": 0.0 },
    "badge": { "type": "STRING" }
  },
  "events": {
    "onTap": {},
    "onAddToCart": {}
  }
}

This schema is the contract between your mobile app and your backend.

Step 3: Initialize Pyramid

In your Application class:

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        
        Pyramid.init(
            context = this,
            config = PyramidConfig(
                serverUrl = "https://api.pyramid.dev",
                apiKey = BuildConfig.PYRAMID_API_KEY
            )
        )
    }
}

Step 4: Sync Your Schemas

Install the Pyramid CLI and sync your component schemas to the server:

# Install CLI
npm install -g @pyramid/cli

# Login
pyramid login --api-key your_api_key

# Initialize (creates pyramid.json in project root)
pyramid init

# Sync schemas (after building your app with KSP)
pyramid sync

Output:

✓ Found 3 components, 0 models, 0 custom actions
✓ Schema bundle v1.0.0 created (checksum: a1b2c3d4)
✓ Code generation started (run_id: run_abc123)
✓ Generated DSL package: dev.pyramid.yourorg:dsl:1.0.0

Step 5: Build Screens from Your Backend

Now your backend team can compose screens using your actual components:

Option A: Kotlin DSL (for Kotlin/JVM backends)

// build.gradle.kts (backend)
dependencies {
    implementation("dev.pyramid.yourorg:dsl:1.0.0")
}

// Usage — full autocomplete and type safety!
fun buildHomeScreen(): PyramidScreen {
    return pyramid {
        screen("home") {
            title("Featured Products")
            
            column(gap = 16) {
                productCard(
                    title = "Wireless Headphones",
                    imageUrl = "https://cdn.example.com/headphones.jpg",
                    price = 79.99,
                    badge = "New",
                    onTap = navigate("/products/headphones"),
                    onAddToCart = api("/cart/add", method = "POST", body = mapOf(
                        "productId" to "headphones-001"
                    ))
                )
                
                productCard(
                    title = "USB-C Hub",
                    imageUrl = "https://cdn.example.com/hub.jpg",
                    price = 49.99,
                    onTap = navigate("/products/hub")
                )
            }
        }
    }
}

Option B: TypeScript SDK (for Node.js backends)

import { screen, action } from '@pyramid/backend-sdk';
import { ProductCard } from './generated/components';

const homeScreen = screen('home')
    .title('Featured Products')
    .body(
        ProductCard.create({
            title: 'Wireless Headphones',
            imageUrl: 'https://cdn.example.com/headphones.jpg',
            price: 79.99,
            badge: 'New',
            onTap: action.navigate('/products/headphones'),
            onAddToCart: action.api('/cart/add', 'POST', {
                productId: 'headphones-001'
            }),
        }),
        ProductCard.create({
            title: 'USB-C Hub',
            imageUrl: 'https://cdn.example.com/hub.jpg',
            price: 49.99,
            onTap: action.navigate('/products/hub'),
        })
    )
    .build();

// Serve via Express
app.get('/screens/home', (req, res) => {
    res.json(homeScreen);
});

Step 6: Render Server Screens in Your App

Replace your hardcoded screen with Pyramid's renderer:

@Composable
fun HomeScreen() {
    // This fetches and renders the server-defined screen
    PyramidScreen(
        screenId = "home",
        loading = { CircularProgressIndicator() },
        error = { error -> 
            Text("Failed to load: ${error.message}")
        }
    )
}

That's it. When your backend changes the screen definition, the app renders the new version — no release needed.

Step 7: Deploy a Change

Let's say marketing wants to add a "Sale" badge to the headphones. Your backend developer updates one line:

productCard( title = "Wireless Headphones", imageUrl = "https://cdn.example.com/headphones.jpg", - price = 79.99, - badge = "New", + price = 59.99, + badge = "🔥 Sale", onTap = navigate("/products/headphones"), )

Deploy the backend. Users see the change in seconds. No app store review. No waiting.


Bonus: A/B Test a Screen

Pyramid's Live Values let you experiment on any property:

// Backend
fun buildHomeScreen(userId: String): PyramidScreen {
    return pyramid {
        screen("home") {
            // Live Value — 50% of users see each variant
            val ctaText = liveValue("home_cta_text", 
                default = "Shop Now",
                variants = mapOf("variant_b" to "Browse Deals")
            )
            
            column {
                // ... products
                button(
                    text = ctaText,
                    style = "primary",
                    onTap = navigate("/shop")
                )
            }
        }
    }
}

Pyramid automatically tracks which variant each user sees and measures engagement. No extra analytics code needed.


What's Next?


FAQ

Does this slow down my app?

No. Pyramid renders your actual native Compose components. There's no WebView, no interpretation overhead. The JSON is parsed once and rendered natively.

What if the server is down?

Pyramid caches the last successful response. Your app always has a fallback. You can also bundle default screens in your APK.

Can I use this for just part of my app?

Absolutely. Most teams start with one or two screens (home, onboarding, promotions) and expand over time. Pyramid works alongside your regular Compose code.

How does this compare to Jetpack Compose Navigation?

They're complementary. Pyramid handles what a screen shows; Compose Navigation handles how you get there. Pyramid integrates with your existing navigation setup.

What about offline support?

Pyramid's SDK includes local caching. Screens load from cache when offline and update when connectivity returns.

Related Articles

Ready to Add SDUI to Your App?

Pyramid helps mobile teams implement Server-Driven UI with their existing Jetpack Compose components. Bring your own components — we handle the infrastructure.

Join the Waitlist