Expo でネイティブ優先のソーシャルプラットフォームを作る
ある修士課程のプロジェクトが数千項目を支えるソーシャル辞書udictioへと成長し、Expoが単なる出発点ではなく中核アーキテクチャとして機能することを証明した話
日本語
コピー

udictio は修士課程の課題として始まり、数千件のエントリを抱える本番運用のソーシャルプラットフォームになった。
ほとんどのソーシャル投稿は、消えるために生まれてくる。フィードに流れ込み、注目を奪い合い、ほどなく二度と見つからなくなる。
僕が作りたかったのはその逆だ。どんな投稿も、それが語っている対象にずっと結びついたままになるソーシャルプラットフォーム。
そのアイデアが後に udictio になった。共同編集型のソーシャル辞書だ。人、映画、製品、場所、出来事、考え、感情——どれでも永続的なトピックページを持てる。人は自分の知識と経験にもとづいて独立したエントリを追加し、そのエントリは次に訪れた人のために残る。
エントリはデフォルトで時系列に並ぶ。本名は必須ではないが、各エントリは通報でき、モデレーションで削除できる。フォロワー数と投票の合計は意図的に非表示にし、広告もなければ、どの投稿が体験全体を支配するかを決めるアルゴリズムもない。
udictio はもともと、ネバダ大学リノ校の Media Innovation プログラムにおける僕の修士課題だった。その後2年ほどモバイルアプリの開発を続け、2026年に App Store で公開した。現在は数千件のユーザー執筆エントリに加え、プロフィール、ダイレクトメッセージ、通知、フォロー、モデレーション、画像アップロード、投票、ブックマーク、下書き、検索、ディープリンク、そして一連の認証フローを支えている。
モバイルアプリは最初からずっと Expo プロジェクトだ。
問いは、Expo でネイティブコードを避けられるかではない。製品が本当に必要とする箇所にだけネイティブコードを足すとき、Expo がプロジェクトの中心であり続けられるかだ。
答えはイエスだった。
Expo は出発点ではなくアーキテクチャ
udictio は Expo Router、React Native、TypeScript、Apollo Client、そして Django GraphQL バックエンドを使っている。ルーティング、通知、画像、セキュアストレージ、音声、スピーチ、ローカライズ、翻訳、アップデート、シンボル、ウィジェット、SwiftUI コントロール、ガラス効果、その他多くの細かなプラットフォーム固有の処理は、Expo のパッケージが担っている。
公式パッケージで解決できる問題は公式パッケージで解決する。API が新しすぎたり特殊すぎたりするときだけ、できる限り小さなネイティブの境界を足す。
-
config plugin を1つ。ネイティブプロジェクトへの変更を再現可能にするため
-
Swift ファイルをいくつか。App Intents 用にメインの app target へコンパイルする
-
責務を1つに絞った Expo module を1つ。Core Spotlight のインデックス用
生成される ios ディレクトリは Git に入れない。Expo Prebuild と Continuous Native Generation がローカルでも EAS Build 上でも再生成し、app 設定、plugin、ネイティブソースが常に唯一の信頼できる情報源となる。
React Native アプリと手作業で保守するネイティブプロジェクトが、少しずつ別々にずれていくのは望んでいない。欲しいのは統一された Expo プロジェクトで、そのネイティブ成果物はいつでも再生成できるものだ。

Appを開かず、Siriからudictioに投稿する
最も分かりやすい例は udictio の投稿intentだ。
ユーザーは「udictio に投稿して」と言い、トピックと本文を伝えると、Siriから確認が返る。画面は一切表示されず、React Nativeランタイムも起動しない。
動画:siriai-gif-16x9 — expo.devで視聴
この制約がアーキテクチャを決めた。バックグラウンドのApp IntentはJavaScriptが生きている前提を持てない。パラメータの収集、認証、リクエスト送信、結果の解釈、有用なフィードバックの返却まで、すべてSwift側で完結させる必要がある。
以下のコードは流れを示すために簡略化したものだ。プロダクション版にはバリデーション、ステータスごとのエラー処理、厳密なURLエンコード、追加のライフサイクル処理が入っている。
struct PostEntryIntent: AppIntent {
static var openAppWhenRun = false
func perform() async throws -> some IntentResult & ProvidesDialog {
let topic = try await collectTopic()
let content = try await collectEntry(for: topic)
guard let token = SiriKeychain.readToken() else {
return .result(
dialog: "Open udictio and log in to turn on Siri posting."
)
}
let result = try await SiriAPI.createEntry(
topic: topic,
content: content,
token: token
)
return .result(dialog: result.spokenResponse)
}
}
このintentが送るのはアプリと同じGraphQL mutationなので、バリデーション、モデレーション、レート制限、BAN、トピック作成は引き続きDjangoバックエンドが担う。
アプリ通常時のセッション認証情報をSiriに渡すこともしていない。バックエンドが別途、取り消し可能でエントリ作成のみに権限を絞ったtokenを発行する。React Nativeはexpo-secure-storeに保存し、Swiftは同じKeychainエントリを読む。ログアウトで削除され、アカウントの所有権はローカルで検証され、ユーザーはSiri投稿を個別にオフにできる。
シミュレータでは見えなかったライフサイクルバグ
App Intentsについて最も価値のある教訓は、実機での失敗から得た。
最初の実装は、先にネットワークリクエストを送り、その後でSiriのプロンプトを表示する順序だった。シミュレータはこの順序を受け入れた。実機のiPhoneでは、ダイアログの表示がintentを中断させ、リクエストはそのまま殺された。
信頼できる順序はこうだ:
不足している値をすべて収集する
すべてのプロンプトを完了する
ネットワークリクエストを送る
書き込み完了後に初めて、アプリを開くよう促す
コードに問題はなく、シミュレータも同意見だった。実機は同意しなかった。
App IntentsをExpoワークフローに留める
udictio は他にもいくつかのintentを公開している。特定のトピックを開く、トレンドトピックを読む、今日アクティブなトピックを読む、特定のトピックを開いて要約を実行可能な状態にする、の4つだ。
AppleのApp Intentsメタデータ抽出は、podに置かれたSwiftファイル内のショートカットを検出しない。これらのファイルはメインアプリのtargetにコンパイルする必要がある。
iOSプロジェクトは生成されるため、Xcodeを直接編集する方法は取らなかった。Swiftソースを生成されたappディレクトリにコピーし、targetのSources build phaseに登録するconfig pluginを書いた。このpluginは冪等で、ローカルのprebuildでもEASビルドでも動作する。
expo-widgetsで作ったホーム画面ウィジェット
udictio は無限スクロールのフィードを中心にはしていないが、刻々と変化するトレンドトピックのリストがある。ホーム画面は、みんなが何を書いているかをひと目で見るのに自然な場所になった。
ウィジェットはsmall、medium、largeの3サイズに対応し、light、dark、tinted、vibrantの各レンダリングモードに適応する。タップすると udictio が開き、トレンドセクションが選択される。

画面は expo-widgets と @expo/ui で構成されている。Expo の config plugin が prebuild 段階で widget extension と共有 App Group を生成する。
本当に厄介なのはレイアウトではなく、widget を独立したランタイムでありストレージ境界として扱わなければならない点だ。
widget 関数はシリアライズされ、隔離された JavaScriptCore 環境で実行される。任意のモジュール状態に気軽にアクセスすることはできず、app のランタイムでは動く書き方でも widget では挙動が変わる。だからレイアウトを絞り、依存関係を明示し、データ契約を小さく保った。
スナップショットデータは共有 App Group 内の property list ストレージを経由する必要がある。undefined や NaN のような未対応の値、あるいはシリアライズできないオブジェクトが一つでもあると、timeline 全体が保存できなくなる。
function buildWidgetProps(topics, iconUri) {
const safeTopics = topics.map((topic) => ({
title: String(topic.title ?? ''),
slug: String(topic.slug ?? ''),
count: Number.isFinite(topic.count)
? Math.trunc(topic.count)
: 0,
}));
return typeof iconUri === 'string'
? { topics: safeTopics, iconUri }
: { topics: safeTopics };
}
そこで開発期に読み戻しを挟み、更新が成功したと決めつけず、データが実際に共有コンテナに入ったかを確認するようにした。topic データは即座にプッシュし、アイコンはバックグラウンドで App Group にコピーしてから、もう一度リフレッシュをトリガーする。
動画:gif-widget-16x9 — expo.dev で視聴
extension target、App Group、ネイティブ UI bridge、更新の仕組みは Expo が受け持つ。アプリ側のコードが守るべきは、向こう側のライフサイクルとシリアライズのルールだけだ。
集合知をデバイス上で要約する
udictio の topic には、互いに独立した意見が多数含まれることがある。Apple の Foundation Models フレームワークはこれを可能にする。読者が意見の全体像をつかめるようにしつつ、内容をサードパーティの AI サービスに送らずに済む。
Apple のモデルセッションへの橋渡しには react-native-apple-llm を使った。その周りに、製品として必要なものを自分で組んだ。可用性チェック、フィルター条件に沿った項目の収集、token 予算ごとの分割、map-reduce 方式の要約、ストリーミング出力、キャンセル、セッションの解放、そしてユーザー向けのエラー処理だ。
要約の入口は Apple Intelligence が利用可能で、かつその topic に少なくとも 5 件の項目があるときだけ表示される。要約の対象は、読者がそのとき選択しているビューそのものだ。今日の項目、人気の項目、画像付きの項目、検索結果などが含まれる。
動画:apple-intelligence-16x9 — expo.dev で視聴
小さな topic はモデルセッション一つで済む。大きな topic は境界を決めて断片に切り、それぞれ要約してから一つの最終結果にまとめる。プロンプトは udictio の構造を反映している。項目は独立した定義、観察、逸話、体験であり、会話の返信とは限らない。
本番で最も重大だった問題は、生成中にユーザーが要約を閉じたときに起きた。キャンセル後もストリーミングインターフェースがネイティブイベントを受け取り続け、閉じたストリームに書き込もうとしてクラッシュし、Sentry に捕捉された。
そこでイベントエミッター方式に切り替えた。各イベントはその時点までの完全なレスポンスを含み、キャンセルは UI の更新を止めるだけなので、遅れて届くネイティブイベントは無害になる。実行ごとにモデルセッションを生成・破棄し、モデルを不必要に常駐させないようにもした。
こうした仕組みは UI からは見えない。読者に見えるのは、落ち着いた「summarizing...」の状態、Apple Intelligence に着想を得た Skia と Reanimated のボーダーエフェクト、そして簡潔な結果と、内容はデバイス上で生成されるため不正確な場合があるという注記だ。
狙いは元の項目を置き換えることではなく、読者が何を深掘りしたいかを決める手助けをすることにある。
アプリ全体を調和させるネイティブの細部
最も踏み込んだ統合は App Intents、ウィジェット、デバイス上の要約だ。ほかにも小さめの機能がいくつかあり、同じ Expo 優先の考え方が製品の残りの部分にも広がることを示している。
翻訳: expo-translate-text は項目の本文とネタバレ部分だけを翻訳する。リンク、メンション、トピック参照、画像、書式はそのまま残す。
読み上げ: iOS では、項目のテキストを Apple のデバイス上の音声合成でキャッシュ済み音声ファイルに変換し、expo-audio で再生する。これによりバックグラウンド再生とシステムのメディアコントロールに対応する。
Spotlight: 小さな Expo モジュールが、最近閲覧したトピックを Core Spotlight に 30 日間登録する。ログアウト時にこれらの記録を消す。閲覧履歴は個人のプライバシーに属するものだからだ。
ストーリー共有: udictio は 9:16 の項目カードをレンダリングし、Instagram や Facebook Stories に送信したり、1080×1920 の画像として保存したり、システムの共有シートから共有したりできる。
iOS 26 と Liquid Glass: Expo Router がネイティブのナビゲーション、検索、ツールバー、フォームシートを提供する。@expo/ui が SwiftUI のコントロールを追加し、expo-glass-effect は選択的に使い、古い iOS では適応型マテリアルをフォールバックとして採用する。

これらの機能はそれぞれ別の Apple フレームワークを使うが、常に同じ Expo アプリの延長であり、それぞれが一つのことに集中している。
このやり方が続く理由
三つの決定が、どんな単一の API よりも重要だ。
ネイティブ境界を狭く保つ
Siri のレイヤーは少数のリクエストだけを処理する。Spotlight モジュールはトピックのインデックスと削除を担う。ウィジェットはコンパクトなスナップショットを受け取る。ネイティブコードが製品の第二のバージョンになることはない。
ネイティブの変更を再現可能にする
拡張、entitlements、ソースファイル、ビルド設定はすべて app 設定と config plugin に書き、記録の残らない Xcode の変更として散らばらせてはいけない。Prebuild はプロジェクト全体を再生成できなければならない。
フォールバック経路とバイナリ互換性を機能の一部として扱う
Apple Intelligence はどこでも使えるわけではない。ソーシャルプラットフォームへの共有ターゲットが消えることもある。アイコンのダウンロードが失敗することもある。新しい JavaScript バンドルが、新しいモジュールを欠いたバイナリ上で動くこともある。
udictio はオプションの統合を保護し、app バージョンのランタイムポリシーでネイティブの変更を扱い、コード署名された expo-updates エンドポイント経由で純粋な JavaScript の修正を配信する。セッションの途中で強制的に中断を伴う reload をさせることはない。
Expo がこの規模を現実的にする
udictio はもともと学術的なアイデアだった。ソーシャルな知識をもっと長く残す、というものだ。それが今では数千件のコンテンツを抱える本番のソーシャルプラットフォームになり、モバイルアプリは iOS の最新機能にまで踏み込んでいる。
Expo はただ素早く立ち上げるのを助けてくれただけではない。アプリが、無関係な JavaScript とネイティブの二つのコードベースに分裂することなく、拡張し続けられるようにしてくれた。
プラットフォーム層の作業の大半は公式パッケージが処理する。本当に足りない部分は焦点を絞った Swift で補う。Continuous Native Generation がネイティブプロジェクトを再現可能に保ち、プロジェクト全体で唯一の信頼できる情報源は常に一つの Expo プロジェクトだ。
私にとって Expo は、開発速度とネイティブ能力の間の妥協ではない。このアーキテクチャがあるからこそ、一人でもこの規模のモバイルプロダクトを構築し、維持できる。
Expo は深くネイティブなアプリの土台になれる。天井にはならない。