Rorkアプリのナビゲーションを組み直さずに済ませる — Expo Router の設計手順
個人開発でRork製のアプリを育てていると、画面数が増えた段階でほぼ必ずナビゲーションの作り直しに直面します。私自身、最初はタブ4つで足りていた構成が、詳細画面と設定モーダルを足すうちに遷移の整理がつかなくなり、一度まるごと組み直したことがありました。原因はシンプルで、生成されたコードを読む前に「どのファイルがどの画面に対応するのか」を把握していなかったことです。
Rorkが生成するReact Native / Expoアプリには、Expo Routerというファイルベースのルーティングシステムが採用されています。この仕組みを先に理解しておくと、Rorkへ渡すプロンプトの精度が変わり、後からの作り直しもぐっと減ります。タブ・スタック・モーダルの実装から認証ガード・ディープリンクまで、私が個人開発の現場で実際に手を動かしている順序で整理していきます。
Expo Routerとは?
Expo Routerはファイルシステムをそのままルート定義として使う、Next.jsライクなルーティングライブラリです。app/ ディレクトリ内のファイル構造がそのままURLパスとナビゲーション構造になります。
Rorkが生成するアプリはすでにExpo Routerが設定済みですが、「どのファイルがどの画面に対応するか」を理解しておくと、Rorkへのプロンプトの精度が大幅に向上します。
ディレクトリ構造の基本
app/
├── _layout.tsx ← ルートレイアウト(共通設定)
├── index.tsx ← ホーム画面(/)
├── (tabs)/ ← タブナビゲーション
│ ├── _layout.tsx ← タブ定義
│ ├── home.tsx ← ホームタブ
│ ├── explore.tsx ← 探索タブ
│ └── profile.tsx ← プロフィールタブ
├── (auth)/ ← 認証グループ
│ ├── login.tsx
│ └── signup.tsx
└── modal.tsx ← モーダル画面
カッコ () で囲まれたフォルダはグループと呼ばれ、URLには含まれませんが、レイアウトをネストするために使います。ここは最初に必ず腹落ちさせておきたい箇所です。(tabs)/home/[id].tsx の実際のパスは /tabs/home/123 ではなく /home/123 です。私はここを勘違いしたまま router.push('/tabs/home/1') と書き、遷移が無反応になる原因を半日探しました。ディレクトリ名とURLが一致しない唯一の例外がグループだと覚えておくと、以降の遷移コードで迷いません。
タブナビゲーションの実装
スマホアプリで最もよく使われる画面下部のタブバー。Rorkに対して以下のようなプロンプトを書くと、正しいExpo Router形式のタブナビゲーションが生成されます。
Rork向けプロンプト例:
ホーム、検索、お気に入り、プロフィールの4タブを持つ
アプリを作成してください。
各タブにはアイコン(Ionicons)と日本語ラベルを表示してください。
生成される (tabs)/_layout.tsx の典型的な構造:
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
headerShown: false,
}}
>
<Tabs.Screen
name="home"
options={{
title: 'ホーム',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: '検索',
tabBarIcon: ({ color, size }) => (
<Ionicons name="search" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="favorites"
options={{
title: 'お気に入り',
tabBarIcon: ({ color, size }) => (
<Ionicons name="heart" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'プロフィール',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
</Tabs>
);
}スタックナビゲーションの実装
詳細画面やフォームのような「進む・戻る」操作が必要な画面にはスタックナビゲーションを使います。
タブ内にスタックをネストする
app/
└── (tabs)/
├── _layout.tsx
└── home/
├── index.tsx ← ホーム一覧(/home)
└── [id].tsx ← 詳細画面(/home/123)
動的ルート([id].tsx)を使うと、パラメータを含む画面を作れます。
// app/(tabs)/home/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { View, Text } from 'react-native';
export default function DetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<View style={{ flex: 1, padding: 16 }}>
<Text>アイテムID: {id}</Text>
</View>
);
}詳細画面への遷移
import { router } from 'expo-router';
// 押したときに詳細画面へ遷移
<TouchableOpacity onPress={() => router.push(`/home/${item.id}`)}>
<Text>{item.title}</Text>
</TouchableOpacity>Rork向けプロンプト例:
ニュース一覧画面から記事詳細画面に遷移するアプリを作ってください。
詳細画面にはニュースのIDをURLパラメータで渡し、
戻るボタンで一覧に戻れるようにしてください。
モーダル画面の実装
設定画面や確認ダイアログなど、全画面モーダルが必要な場合のパターンです。
// app/_layout.tsx(ルートレイアウト)
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="modal"
options={{
presentation: 'modal',
title: '設定',
}}
/>
</Stack>
);
}モーダルを開くには:
import { router } from 'expo-router';
<TouchableOpacity onPress={() => router.push('/modal')}>
<Text>設定を開く</Text>
</TouchableOpacity>認証ガードの実装
未ログインユーザーをログイン画面にリダイレクトするパターンは、Rorkでよく必要になります。
// app/_layout.tsx
import { useEffect } from 'react';
import { Slot, useRouter, useSegments } from 'expo-router';
import { useAuth } from '@/hooks/useAuth';
export default function RootLayout() {
const { user, loading } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (loading) return;
const inAuthGroup = segments[0] === '(auth)';
if (!user && !inAuthGroup) {
// 未ログインなのに保護されたページにいる場合
router.replace('/(auth)/login');
} else if (user && inAuthGroup) {
// ログイン済みなのに認証ページにいる場合
router.replace('/(tabs)/home');
}
}, [user, loading, segments]);
return <Slot />;
}Rork向けプロンプト例:
ログインしていないユーザーはログイン画面にリダイレクトされ、
ログイン後はホーム画面に遷移するアプリを作ってください。
認証状態はZustandで管理してください。
ディープリンクの設定
外部URLやプッシュ通知からアプリの特定画面を開くには、ディープリンクの設定が必要です。
app.jsonの設定
{
"expo": {
"scheme": "myapp",
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "myapp.com",
"pathPrefix": "/item"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}Expo Routerはこの設定を元にディープリンクを自動的に処理します。たとえば myapp://home/123 というURLを開くと、app/(tabs)/home/[id].tsx(id=123)が表示されます。
マウント前に遷移して落ちるとき
認証ガードを入れた直後によく踏むのが、Attempted to navigate before mounting the Root Layout component というエラーです。useEffect の中で router.replace() を呼んでいるのに、ルートレイアウトのナビゲータがまだ組み上がっていない、という状態で発生します。
私の場合、認証状態の復元を AsyncStorage から読む処理が速すぎて、初回描画の前に遷移が走ったのが原因でした。対処は二段構えにしています。
// app/_layout.tsx
import { useEffect } from 'react';
import { Slot, useRouter, useSegments, useRootNavigationState } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useAuth } from '@/hooks/useAuth';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const { user, loading } = useAuth();
const segments = useSegments();
const router = useRouter();
const navState = useRootNavigationState();
useEffect(() => {
// ① ナビゲータのマウント完了を待つ
if (!navState?.key) return;
// ② 認証状態の復元完了を待つ
if (loading) return;
const inAuthGroup = segments[0] === '(auth)';
if (!user && !inAuthGroup) {
router.replace('/(auth)/login');
} else if (user && inAuthGroup) {
router.replace('/(tabs)/home');
}
SplashScreen.hideAsync();
}, [navState?.key, user, loading, segments]);
return <Slot />;
}useRootNavigationState() が返す key は、ナビゲータが実際に組み上がったあとに初めて値を持ちます。これを見張ってから遷移すると、起動直後のクラッシュがなくなります。あわせてスプラッシュを手動で閉じることで、ログイン画面が一瞬だけちらつく現象も消えました。
もう一点。認証の判定ロジックそのものをどこに置くかは、Rork でユーザー認証を実装する — Firebase・Supabase 連携 の内容とあわせて考えると整理しやすいはずです。ナビゲーション層は「判定結果を受け取って行き先を決めるだけ」に保つのが、後から認証基盤を差し替えるときに効いてきます。
見つからない画面を先に置いておく
ディープリンクを有効にすると、タイプミスや古いURLでアプリが開かれる場面が必ず出てきます。Expo Router には +not-found.tsx という予約ファイル名があり、どのルートにも一致しなかったときにここが表示されます。
// app/+not-found.tsx
import { Link, Stack } from 'expo-router';
import { View, Text } from 'react-native';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'お探しの画面が見つかりません' }} />
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<Text style={{ marginBottom: 16 }}>
リンクが古いか、画面が移動した可能性があります。
</Text>
<Link href="/(tabs)/home" replace>
<Text style={{ color: '#007AFF' }}>ホームに戻る</Text>
</Link>
</View>
</>
);
}ここで Link に replace を付けているのは、履歴に「見つからない画面」を残さないためです。付け忘れると、ホームに戻ったあとに端末のバックジェスチャーでまたエラー画面に戻ってしまいます。細かい話ですが、実機で触ると気持ち悪さがすぐ分かります。
ディープリンクの動作確認は、ビルドし直さなくてもターミナルから叩けます。
# iOS シミュレータ
xcrun simctl openurl booted "myapp://home/123"
# Android エミュレータ
adb shell am start -W -a android.intent.action.VIEW -d "myapp://home/123"存在しないIDを渡して +not-found に落ちること、正しいIDで詳細画面が開くこと。この2つを確認してから申請に進むと、レビューでのリジェクトを一つ減らせます。
ナビゲーション設計のベストプラクティス
Rorkで高品質なナビゲーション設計を実現するための5つのポイントを紹介します。
1. 最初にナビゲーション構造を決める
アプリを作り始める前に、どの画面がどのタブに属するか、どの画面がモーダルで開くかを明確にしてからプロンプトを書きましょう。
2. プロンプトで画面遷移を具体的に書く
「一覧から詳細に遷移」「バックボタンで戻る」「ログイン後はホームへ」など、遷移の流れを明示的に伝えると生成精度が上がります。プロンプトの粒度そのものについてはRork に渡すプロンプトの書き方 — アプリ生成の精度を高める7つのコツも参考になります。
3. グループを使ってレイアウトを整理する
認証フロー用の (auth)/、メインアプリ用の (tabs)/、管理画面用の (admin)/ のようにグループを活用すると、ファイル構造が整理されます。
4. router.push と router.replace を使い分ける
router.push()→ 履歴に追加(戻るボタンで戻れる)router.replace()→ 履歴を置き換え(ログイン後のリダイレクトなど)router.back()→ 前の画面に戻る
5. ヘッダーを一箇所で管理する
各画面で個別にヘッダーを設定せず、_layout.tsx の screenOptions でスタイルを一元管理しましょう。
次に触るなら、この順番で
冒頭で触れた「まるごと組み直し」を避けられるようになったのは、画面を足す前にディレクトリ構造を紙に書き出す習慣がついてからでした。グループはURLに出ない。遷移はマウント後。行き先が無いときの受け皿を先に置く。この3つを最初に押さえておくだけで、手戻りの大半は消えます。
今日から動かすなら、app/ を開いてグループの入れ子を確認し、+not-found.tsx を1枚追加してみてください。それだけで、ディープリンクを本気で設定する準備が整います。
タブから始めて、必要になった順にスタックとモーダルを重ねる。私はこの積み方に落ち着きました。実装の参考になれば幸いです。