ユニバーサル化への道:ブラウンフィールドの React Native と Next.js スタックから 1 つの Expo アプリへ
Users • React Native • November 19, 2025 • 9 minutes read
Gunnar Torfi(ゲスト著者)
小さなチームが、ブラウンフィールドの React Native と Next.js のスタックを 1 つのユニバーサルな Expo アプリに統合しました。大掛かりな書き換えはなし。共有 UI と共有機能への着実なステップだけです。
概要
私たちは小さなチームです。40 以上の画面を持つ Expo アプリと、Next.js の Web アプリがありました。両方を同期させる人手が足りませんでした(実際はこのプロダクトは単独の開発者が維持しています)。そこで、すべてを二度作るのをやめることにしました。段階的にユニバーサル化したのです。本稿は、Noona を大規模リライトなしでユニバーサルアプリに変えた方法の記録です。
出発点
- 2 つのアプリ、2 つのコンポーネント群、ルーティングや解析、認証の実装も別々。
- バグを片方で直すたびに、もう片方に反映されない――機能の乖離、コピー&ペースト、レビュー疲れ。
モバイル: Expo (React Native)
Web: Next.js
現実: 機能の乖離、重複コード、レビュー疲労
この苦労は多くの方が経験しているはずです。私たちにとっては、同じものを2回作り続けるのは意味がありませんでした。そこで移行を始めました。
最初の小さな勝利:モノレポと 1 つの共有 Button
Turborepo ベースのモノレポに移行し、最小構成にしました:
apps/web(既存の Web アプリ)
apps/native(既存の Expo アプリ)
packages/ui(共有の React Native コンポーネント)
packages/config(TS、ESLint、Prettier)
最初の目標はシンプル:Button を共有して、web、iOS、Android でレンダリングすること。これが round‑trip(往復)で動けば道は見えます。
やるべきことは:TypeScript エラーがないこと、両プラットフォームで動作する production ビルドができること、チェックポイントとして明確なコミットを残すこと。
なぜこれが効くのか:
- 「コードをコピー」する思考を止めて「コードを共有」する思考に変えられる。
- ビルドやツール周りの前提に素早くフィードバックが得られる。
- Turborepo はワークスペースとキャッシュがシンプルに使える。初めての場合は彼らの “structuring a repository” ドキュメントから始めると良い。
共有 UI:React Native → Web
packages/ui を React Native のプリミティブ上に作り、react-native-web で Web DOM に適合させました。最初から作った便利コンポーネントの例:
- Nativewind:共有レイヤーで Tailwind を使うために Nativewind を native 側で使用。CSS 変数とダークモード周りで調整に時間はかかったが価値あり。
- Image:native 側は
expo-image、web 側は next/image を使用。
- Icons:native は
lucide-react-native、web は lucide-react。各アイコンは earth.tsx と earth.web.tsx のようにファイルを分けている。
- Link:native は
expo-router の Link、web は next/link を使用。
- 各種プラットフォーム固有コンポーネント:必要に応じてプラットフォーム固有実装を持たせている(例:Radix/base-ui は web、ネイティブはネイティブピッカーを使うなど)。その結果、チェックボックス、read more コンポーネント、スイッチ、日付ピッカー、modal root、Apple Pay ボタン、ポップオーバー、ポータル、ラジオボタンなどを個別実装した。
フック類
移行中に有用なフックを多数蓄積しました。各フックは native と web で別実装になっているものが多く、片方では no-op、もう片方でプラットフォーム API を呼ぶもの(例:useSearchParams)もあります。
例:useNavigate(native は expo-router、web は next/router)、useSearchParams、useFocusEffect、use3DSecure、useSessionStorage、useApplePay、useWindowFocus。
react-native-web のリポジトリはこの "RN to DOM" のジャンプに慣れるための良い出発点です。
機能はアプリのファイルではなくパッケージとして生きる
新しい機能は packages/ui/features/feature-* に入ります。各 feature は両アプリでレンダリングできるコンポーネントをエクスポートします。構成は自由で、各機能を別パッケージにしたり、すべてを 1 つのパッケージにまとめたりできます。
- Web:ルートで feature を import
- Native:スクリーンで feature を import
この設計を薄いままにしておくことで、毎回機能を一度書いて両プラットフォームで動かすことができました。
薄いプラットフォーム接着層(認証、ルーティング、ディープリンクなど)
これが私たちの継続的なデリバリを支えました。すべての新機能は一度書かれ、両方で動作しました。
『一時的プロバイダー』が実際の動作を可能にした
認証や解析スタックを初日にすべて移行することはしませんでした。代わりに、共有 UI をラップする一時的なプロバイダーを作り、user、analytics.track、フラグ、テーマなどを受け取れるようにしました。両アプリがそれぞれの実装を渡し、共有 UI はそのインターフェースを呼び出します。これにより、数ヶ月に及ぶ配線作業を待たずに今日から作業を始められました。
ルーティング:Expo Router に移行
途中でモバイルアプリを Expo Router に切り替えました。理由は「将来の web 対応」のためです。ファイルベースのルートとディープリンクはプラットフォームを超えて同じメンタルモデルを与えてくれます。準備ができれば web もここに置けます。
Expo Router は native と web 向けのファイルベースルーティングで、React Navigation の上に構築されています。
長い中間期間:ゆっくり着実、地味な作業
ここからは手作業でした。プロダクトを止めて「移行」を行うことはしませんでした。触ったときに移す、という方針です。
- 機能に触る?
packages/ui/features/feature-* に移してからリリース。
- コンポーネントに触る?
packages/ui に移してからリリース。
- 休暇や夏の週は小さなリファクタの良い時間。
このやり方には副次効果があり、Web と Mobile の機能差が徐々に埋まりました。
私たちの最初の共有機能は…チェックアウトでした 😅
最初に作ってリリースしたのは小さな設定画面ではなく、Reservation Confirmation(チェックアウト)という最も複雑な画面でした。予約詳細の収集、支払いと 3DS のフロー、予約確定を扱います。
ユニバーサルな支払いインターフェースも作りました(Adyen を中心に):
- native:Adyen の React Native SDK と対話
- web:Adyen の web コンポーネントと対話
カードの追加、支払い選択、CVV 検証、3DS フローなどを扱い、画面のコードパスは 1 つに保ちつつ、支払いレイヤーを .native.ts と .web.ts でエクスポートしました。
切り替え(Cutting over)
ほとんどの機能が共有パッケージに移されたら、次のように進められます:
- Web を共有モジュールと共有 UI に向ける
- Web のルーティング(およびリンクルール)を
Expo Router の構造に合わせる
- 依存がなくなったら古い Next.js アプリを削除
その時点で、すべてを走らせる 1 つの Expo プロジェクトを持つことになります。SEO が重要なら、Expo の static export で web 向けにページをプリレンダリングできます。Expo Router を web で本格採用する場合に有用です。動的コンテンツの良好な SEO のために Expo の SSR サポートが進むのを楽しみにしています。
私たちに効いたこと
- 微視的に始める。1 つの共有 Button がパイプラインを証明する。
- 新しい機能は「新しいやり方」で書く。大規模リライトを待たない。
- 薄い provider ブリッジを維持する。共有コードの即時アンロックになる。
- 触ったときに移す。アクティブに出荷している領域だけをリファクタする。
- 早い段階で手強い機能を一つ選ぶ。抽象化が現実的になる。
1 つだけ再度やるなら変えたいこと:共有 UI を使っている画面の割合(例:% of screens using shared UI)といったシンプルな指標を追うことで士気が上がる。
今後の注目点:react‑strict‑dom 等
react‑strict‑dom の動きや、ネイティブモジュール向けに標準化された Web API への推進に期待しています。React Native 0.82 も refs 経由で DOM ライクなノードを導入しました。この方向性はユニバーサルアプリが特別扱いされる感じを減らし、多数のプラットフォームで動く普通の React のように感じさせてくれます。私たちはこれを注視しています。
最後に
これは数週間ではなく数か月かかりましたが、その間もプロダクトを出荷し続けました。休暇や静かな週が私たちの「移行スプリント」になりました。
もし小さなチームでブラウンフィールドな RN + Next.js セットアップに悩んでいるなら、ドラマなしでこれを成し遂げられます。忍耐強く、1つずつ移してください。積み重なります。
React チームやエコシステムがユニバーサル React を実現するために多くの努力をしているのは明らかです。React の未来は確実にユニバーサルだと私は信じています。
私たちにとってユニバーサル化は単にコード重複を減らすこと以上の意味がありました。プロダクトの作り方が根本的に変わりました。今では Web と Mobile に同時に機能を出荷でき、調整コストが不要になりました。デザインシステムは一貫し、バグ修正はどこにでも波及します。小さなチームにとってこの速度は重要です。
もしこの道を検討しているなら、小さく始めてください。1 つの共有コンポーネントでツールが機能することを証明し、次に出荷する各機能に任せてください。ブラウンフィールドの移行は段階的ですが、確実で価値があります。