Server-Driven UI とは何か — なぜ今モバイル開発で注目されているのか
モバイルアプリ開発において、UI の変更のたびに App Store / Google Play の審査を待つという従来のリリースサイクルは、ビジネスのスピードとの間に大きなギャップを生んでいます。Server-Driven UI(SDUI)は、この課題を根本から解決するアーキテクチャです。
SDUI の核心はシンプルです。UI のレイアウト・構成・表示条件をサーバーサイドの JSON レスポンスとして定義し、クライアント(アプリ)はその JSON を解釈してコンポーネントをレンダリングするという設計パターンです。Airbnb、Shopify、Lyft といった大手テック企業がプロダクション環境で採用しており、A/B テストの即時切り替えやパーソナライズド UI の配信に活用されています。
Rork Max は React Native / Expo ベースのネイティブアプリを生成するため、SDUI パターンとの相性が非常に良好です。ここで扱うのはRork Max プロジェクトに SDUI を導入するための設計原則、JSON スキーマ、レンダリングエンジン、バックエンド構成までを体系的に解説します。
SDUI の設計原則 — 3つの基本レイヤー
SDUI アーキテクチャは以下の3層で構成されます。
レイヤー1: スキーマ定義層(Contract Layer)
サーバーとクライアント間の「契約」となる JSON スキーマを定義します。このスキーマが SDUI の要であり、慎重な設計が求められます。
// types/sdui.ts — SDUI コンポーネントの型定義
type SDUIComponentType =
| 'hero_banner'
| 'product_grid'
| 'text_block'
| 'cta_button'
| 'carousel'
| 'feature_list'
| 'testimonial'
| 'spacer';
interface SDUIComponent {
id: string;
type: SDUIComponentType;
props: Record<string, unknown>;
children?: SDUIComponent[];
visibility?: {
conditions: VisibilityCondition[];
operator: 'AND' | 'OR';
};
analytics?: {
impressionEvent: string;
tapEvent: string;
};
}
interface VisibilityCondition {
field: string; // 例: "user.plan", "device.platform"
operator: 'eq' | 'neq' | 'gt' | 'lt' | 'in';
value: string | number | string[];
}
// サーバーレスポンスのルート型
interface SDUIScreenResponse {
screenId: string;
version: number;
ttl: number; // キャッシュ有効期限(秒)
components: SDUIComponent[];
metadata: {
title: string;
analytics_screen_name: string;
};
}
レイヤー2: レンダリングエンジン層(Rendering Layer)
JSON を受け取り、対応する React Native コンポーネントに変換するエンジンです。
// components/sdui/SDUIRenderer.tsx
import React from 'react';
import { View } from 'react-native';
import { HeroBanner } from './HeroBanner';
import { ProductGrid } from './ProductGrid';
import { TextBlock } from './TextBlock';
import { CTAButton } from './CTAButton';
import { Carousel } from './Carousel';
import { FeatureList } from './FeatureList';
// コンポーネントレジストリ — 新しいコンポーネントはここに追加するだけ
const COMPONENT_REGISTRY: Record<string, React.ComponentType<any>> = {
hero_banner: HeroBanner,
product_grid: ProductGrid,
text_block: TextBlock,
cta_button: CTAButton,
carousel: Carousel,
feature_list: FeatureList,
};
interface SDUIRendererProps {
components: SDUIComponent[];
context: UserContext; // ユーザー情報(プラン・デバイス等)
}
export function SDUIRenderer({ components, context }: SDUIRendererProps) {
return (
<View style={{ flex: 1 }}>
{components
.filter((comp) => evaluateVisibility(comp.visibility, context))
.map((comp) => {
const Component = COMPONENT_REGISTRY[comp.type];
if (!Component) {
// 未知のコンポーネントは安全にスキップ(前方互換性)
console.warn(`Unknown SDUI component: ${comp.type}`);
return null;
}
return (
<Component
key={comp.id}
{...comp.props}
analytics={comp.analytics}
>
{comp.children && (
<SDUIRenderer components={comp.children} context={context} />
)}
</Component>
);
})}
</View>
);
}
// 期待される出力: JSON 定義に応じたコンポーネントが動的にレンダリングされる
// 例: hero_banner → <HeroBanner title="..." />, product_grid → <ProductGrid items={[...]} />
レイヤー3: データ配信層(Delivery Layer)
バックエンドから JSON を配信し、クライアントがキャッシュ付きで取得する仕組みです。
// hooks/useSDUIScreen.ts — 画面データの取得とキャッシュ管理
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
const SDUI_CACHE_PREFIX = 'sdui_cache_';
export function useSDUIScreen(screenId: string) {
const [screen, setScreen] = useState<SDUIScreenResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchScreen = useCallback(async () => {
try {
// 1. キャッシュを先にチェック(オフライン対応)
const cached = await AsyncStorage.getItem(
`${SDUI_CACHE_PREFIX}${screenId}`
);
if (cached) {
const parsed = JSON.parse(cached);
const isExpired =
Date.now() - parsed.cachedAt > parsed.data.ttl * 1000;
if (!isExpired) {
setScreen(parsed.data);
setLoading(false);
return;
}
}
// 2. サーバーから最新データを取得
const response = await fetch(
`https://api.example.com/sdui/screens/${screenId}`,
{
headers: {
'X-App-Version': '2.1.0',
'X-Platform': Platform.OS,
},
}
);
const data: SDUIScreenResponse = await response.json();
// 3. キャッシュに保存
await AsyncStorage.setItem(
`${SDUI_CACHE_PREFIX}${screenId}`,
JSON.stringify({ data, cachedAt: Date.now() })
);
setScreen(data);
} catch (err) {
setError(err as Error);
// フォールバック: キャッシュが古くても表示する
const cached = await AsyncStorage.getItem(
`${SDUI_CACHE_PREFIX}${screenId}`
);
if (cached) {
setScreen(JSON.parse(cached).data);
}
} finally {
setLoading(false);
}
}, [screenId]);
useEffect(() => {
fetchScreen();
}, [fetchScreen]);
return { screen, loading, error, refetch: fetchScreen };
}
実装チュートリアル — Rork Max プロジェクトへの SDUI 導入
ステップ1: Supabase でバックエンド API を構築する
SDUI のバックエンドには、画面定義を管理するデータベースと API が必要です。Rork Max と相性の良い Supabase を活用したバックエンド構築をベースに、SDUI 用のテーブルを追加します。
-- Supabase SQL Editor で実行
-- 画面定義テーブル
CREATE TABLE sdui_screens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
screen_id TEXT UNIQUE NOT NULL, -- 例: 'home', 'product_detail'
version INTEGER NOT NULL DEFAULT 1,
components JSONB NOT NULL, -- SDUIComponent 配列
metadata JSONB DEFAULT '{}',
ttl INTEGER DEFAULT 300, -- キャッシュ有効期限(秒)
is_active BOOLEAN DEFAULT true,
targeting JSONB DEFAULT '{}', -- A/Bテスト・セグメント条件
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- バージョン履歴テーブル(ロールバック用)
CREATE TABLE sdui_screen_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
screen_id TEXT REFERENCES sdui_screens(screen_id),
version INTEGER NOT NULL,
components JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS ポリシー: 読み取りは全ユーザー許可、書き込みは管理者のみ
ALTER TABLE sdui_screens ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public read" ON sdui_screens
FOR SELECT USING (is_active = true);
ステップ2: Supabase Edge Function で配信 API を作成
// supabase/functions/sdui-screen/index.ts
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
serve(async (req) => {
const url = new URL(req.url);
const screenId = url.pathname.split('/').pop();
const platform = req.headers.get('X-Platform') || 'ios';
const appVersion = req.headers.get('X-App-Version') || '1.0.0';
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!
);
// 画面定義を取得
const { data, error } = await supabase
.from('sdui_screens')
.select('*')
.eq('screen_id', screenId)
.eq('is_active', true)
.single();
if (error || !data) {
return new Response(
JSON.stringify({ error: 'Screen not found' }),
{ status: 404 }
);
}
// プラットフォーム固有のフィルタリング
const filteredComponents = data.components.filter(
(comp: any) => {
if (!comp.visibility) return true;
return evaluateTargeting(comp.visibility, { platform, appVersion });
}
);
return new Response(
JSON.stringify({
screenId: data.screen_id,
version: data.version,
ttl: data.ttl,
components: filteredComponents,
metadata: data.metadata,
}),
{
headers: {
'Content-Type': 'application/json',
'Cache-Control': `public, max-age=${data.ttl}`,
},
}
);
});
// 期待される出力: { screenId: "home", version: 3, ttl: 300, components: [...], metadata: {...} }
ステップ3: 管理画面から UI を更新する
SDUI の真価は、エンジニアでなくても UI を変更できる管理画面にあります。以下は Supabase の管理画面から直接 JSON を編集する例です。
{
"screenId": "home",
"version": 4,
"ttl": 300,
"components": [
{
"id": "spring-sale-banner",
"type": "hero_banner",
"props": {
"title": "春の特別セール",
"subtitle": "全品 30% OFF — 3月31日まで",
"imageUrl": "https://cdn.example.com/banners/spring-2026.webp",
"action": { "type": "navigate", "screen": "sale_list" }
},
"visibility": {
"conditions": [
{ "field": "date", "operator": "lt", "value": "2026-04-01" }
],
"operator": "AND"
},
"analytics": {
"impressionEvent": "home_spring_banner_viewed",
"tapEvent": "home_spring_banner_tapped"
}
},
{
"id": "recommended-products",
"type": "product_grid",
"props": {
"columns": 2,
"fetchUrl": "/api/products/recommended",
"limit": 6
}
},
{
"id": "premium-cta",
"type": "cta_button",
"props": {
"label": "プレミアムプランを試す",
"variant": "primary",
"action": { "type": "navigate", "screen": "membership" }
},
"visibility": {
"conditions": [
{ "field": "user.plan", "operator": "eq", "value": "free" }
],
"operator": "AND"
}
}
]
}
この JSON をデータベース上で更新するだけで、アプリの UI が即座に変わります。App Store の審査は不要です。
A/B テストとパーソナライゼーション — SDUI の真骨頂
SDUI の最大のメリットの一つが、サーバーサイドでの A/B テスト制御です。
// utils/abTest.ts — A/Bテスト分岐ロジック
interface ABTestConfig {
testId: string;
variants: {
id: string;
weight: number; // 0〜100 の配分比率
components: SDUIComponent[];
}[];
}
export function resolveABTest(
config: ABTestConfig,
userId: string
): SDUIComponent[] {
// ユーザーIDベースの決定論的ハッシュ(同じユーザーには常に同じバリアント)
const hash = simpleHash(`${config.testId}:${userId}`);
const bucket = hash % 100;
let cumulative = 0;
for (const variant of config.variants) {
cumulative += variant.weight;
if (bucket < cumulative) {
return variant.components;
}
}
// フォールバック: 最初のバリアント
return config.variants[0].components;
}
function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0;
}
return Math.abs(hash);
}
// 使用例: CTA ボタンの色とテキストを A/B テスト
// Variant A (50%): 青ボタン「今すぐ始める」
// Variant B (50%): 緑ボタン「無料で試す」
パーソナライゼーションも同様に、ユーザーの属性(プラン・利用頻度・地域)に応じて異なる components 配列を返すことで実現できます。これにより、プレミアム会員には専用の UI を表示し、無料ユーザーにはアップグレードを促す CTA を配置するといった制御が、コードの変更なしで可能になります。
パフォーマンス最適化 — 本番運用で気をつけるポイント
SDUI を本番環境で運用する際、パフォーマンスは最重要課題です。Rork Max アプリのパフォーマンス最適化の知見を活かしつつ、SDUI 固有の最適化を行います。
プリフェッチとキャッシュ戦略
// utils/sduiPrefetch.ts — 画面遷移前にプリフェッチ
import { SDUIScreenResponse } from '../types/sdui';
const prefetchCache = new Map<string, Promise<SDUIScreenResponse>>();
export function prefetchScreen(screenId: string): void {
if (prefetchCache.has(screenId)) return;
const promise = fetch(
`https://api.example.com/sdui/screens/${screenId}`
).then((res) => res.json());
prefetchCache.set(screenId, promise);
// 5分後にキャッシュをクリア
setTimeout(() => prefetchCache.delete(screenId), 5 * 60 * 1000);
}
export async function getPrefetchedScreen(
screenId: string
): Promise<SDUIScreenResponse | null> {
const cached = prefetchCache.get(screenId);
if (cached) {
prefetchCache.delete(screenId);
return cached;
}
return null;
}
// 使用例: ホーム画面表示時に、ユーザーが次に遷移しそうな画面をプリフェッチ
// prefetchScreen('product_detail');
// prefetchScreen('category_list');
コンポーネントの遅延ロード
// components/sdui/LazyRegistry.ts — 必要なコンポーネントだけロードする
import { lazy, ComponentType } from 'react';
const LAZY_REGISTRY: Record<string, () => Promise<{ default: ComponentType<any> }>> = {
hero_banner: () => import('./HeroBanner'),
product_grid: () => import('./ProductGrid'),
text_block: () => import('./TextBlock'),
cta_button: () => import('./CTAButton'),
carousel: () => import('./Carousel'),
feature_list: () => import('./FeatureList'),
testimonial: () => import('./Testimonial'),
video_player: () => import('./VideoPlayer'), // 重いコンポーネント
};
export function getLazyComponent(type: string): ComponentType<any> | null {
const loader = LAZY_REGISTRY[type];
if (!loader) return null;
return lazy(loader);
}
// 期待される効果: 初期バンドルサイズを削減し、使用されるコンポーネントのみロード
エラーハンドリングとフォールバック戦略
本番運用では、サーバーダウンや不正な JSON レスポンスへの対処が不可欠です。
// components/sdui/SafeSDUIRenderer.tsx — エラー境界付きレンダラー
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { View, Text, StyleSheet } from 'react-native';
interface ErrorBoundaryState {
hasError: boolean;
errorComponent: string | null;
}
class SDUIErrorBoundary extends Component<
{ componentId: string; children: ReactNode },
ErrorBoundaryState
> {
state: ErrorBoundaryState = { hasError: false, errorComponent: null };
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true, errorComponent: null };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// エラーを分析サービスに送信
console.error(
`SDUI component error [${this.props.componentId}]:`,
error,
info
);
}
render() {
if (this.state.hasError) {
// エラーが起きたコンポーネントだけスキップ(画面全体は壊さない)
return null;
}
return this.props.children;
}
}
// フォールバック画面の定義(ハードコード)
const FALLBACK_SCREENS: Record<string, SDUIComponent[]> = {
home: [
{
id: 'fallback-welcome',
type: 'text_block',
props: {
text: 'アプリをご利用いただきありがとうございます',
variant: 'heading',
},
},
],
};
// サーバーからの取得に失敗した場合、ハードコードされたフォールバックを表示
export function getFallbackScreen(screenId: string): SDUIComponent[] {
return FALLBACK_SCREENS[screenId] || [];
}
テスト戦略 — SDUI の品質を担保する
SDUI では、JSON スキーマの整合性テストが特に重要です。Rork Max のテスト戦略に加えて、SDUI 固有のテストを導入します。
// __tests__/sdui/schemaValidation.test.ts
import Ajv from 'ajv';
import { sduiSchema } from '../schemas/sdui.schema.json';
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(sduiSchema);
describe('SDUI Schema Validation', () => {
it('有効な画面定義を受け入れる', () => {
const validScreen = {
screenId: 'home',
version: 1,
ttl: 300,
components: [
{
id: 'test-banner',
type: 'hero_banner',
props: { title: 'テスト', imageUrl: 'https://example.com/img.webp' },
},
],
metadata: { title: 'ホーム', analytics_screen_name: 'home' },
};
expect(validate(validScreen)).toBe(true);
});
it('未知のコンポーネントタイプを拒否する', () => {
const invalidScreen = {
screenId: 'home',
version: 1,
ttl: 300,
components: [
{ id: 'bad', type: 'nonexistent_widget', props: {} },
],
metadata: {},
};
expect(validate(invalidScreen)).toBe(false);
});
it('必須フィールドの欠落を検出する', () => {
const missingFields = {
screenId: 'home',
components: [], // version と ttl が欠落
};
expect(validate(missingFields)).toBe(false);
});
});
// 期待される出力: 3 tests passed — スキーマの整合性が保証される
個人開発者の視点から(実体験メモ)
ここまでの要点
Server-Driven UI は、モバイルアプリ開発における「リリースの遅さ」という根本的な課題を解決する強力なアーキテクチャパターンです。Rork Max の React Native / Expo 基盤と組み合わせることで、アプリストアの審査を待たずに UI をリアルタイムで更新し、A/B テストやパーソナライゼーションを即座に展開できる柔軟なアプリを構築できます。
本記事で紹介した JSON スキーマ設計、レンダリングエンジン、キャッシュ戦略、エラーハンドリングの各パターンは、そのまま本番プロジェクトに適用可能です。まずはホーム画面の一部セクションから SDUI を導入し、その効果を実感してみてください。