Expo Router v3 で認証ルーティングを実装する方法【実例あり】
結論:useSegments + useRouter + Context で認証ガードを実現する
Expo Router v3 で認証フローを実装するベストプラクティスは、認証状態を Context で管理し、useSegments でルートグループを判定して router.replace() でリダイレクトするパターンです。
公式ドキュメントもこのアプローチを推奨しており、ファイルベースルーティングの構造と組み合わせることで、宣言的かつメンテナブルな認証ガードを構築できます。
ディレクトリ構成の全体像
まずファイル構成を整理します。Expo Router v3 では app/ 配下のディレクトリ構造がそのままルーティングになります。
app/
├── _layout.tsx ← ルートレイアウト(認証チェックはここ)
├── (auth)/
│ ├── _layout.tsx ← 認証不要グループのレイアウト
│ ├── login.tsx
│ └── signup.tsx
└── (app)/
├── _layout.tsx ← 認証済みグループのレイアウト(タブ)
├── (home)/
│ ├── _layout.tsx ← タブ内スタックレイアウト
│ ├── index.tsx
│ └── detail/[id].tsx
└── profile.tsx
(auth) と (app) はルートグループ(括弧付きディレクトリ)なので、URLには影響しません。この構造が認証フロー実装の土台になります。
Step 1:認証 Context を作成する
// context/AuthContext.tsx
import React, { createContext, useContext, useEffect, useState } from "react";
type AuthContextType = {
user: { id: string; email: string } | null;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
isLoading: boolean;
};
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AuthContextType["user"]>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// セッション復元(例: SecureStore や Firebase Auth の onAuthStateChanged)
restoreSession().then((restoredUser) => {
setUser(restoredUser);
setIsLoading(false);
});
}, []);
const signIn = async (email: string, password: string) => {
const loggedInUser = await fakeSignIn(email, password); // APIコール
setUser(loggedInUser);
};
const signOut = async () => {
await fakeSignOut();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, signIn, signOut, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
};
Step 2:ルートレイアウトで認証ガードを実装する
// app/_layout.tsx
import { Slot, useRouter, useSegments } from "expo-router";
import { useEffect } from "react";
import { AuthProvider, useAuth } from "../context/AuthContext";
function RootLayoutNav() {
const { user, isLoading } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (isLoading) return; // セッション復元中は何もしない
const inAuthGroup = segments[0] === "(auth)";
if (!user && !inAuthGroup) {
// 未認証かつ認証不要ページ以外にいる → ログイン画面へ
router.replace("/(auth)/login");
} else if (user && inAuthGroup) {
// 認証済みなのに認証画面にいる → アプリトップへ
router.replace("/(app)/(home)/");
}
}, [user, segments, isLoading]);
return <Slot />;
}
export default function RootLayout() {
return (
<AuthProvider>
<RootLayoutNav />
</AuthProvider>
);
}
ポイント:router.replace() を使う理由
router.push() だと戻るボタンでログイン画面に戻れてしまいます。router.replace() で履歴スタックを置き換えることで、認証済みユーザーが「戻る」でログイン画面に戻るのを防げます。
Step 3:認証不要グループのレイアウト
// app/(auth)/_layout.tsx
import { Stack } from "expo-router";
export default function AuthLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="login" />
<Stack.Screen name="signup" />
</Stack>
);
}
// app/(auth)/login.tsx
import { useRouter } from "expo-router";
import { useState } from "react";
import { Button, Text, TextInput, View } from "react-native";
import { useAuth } from "../../context/AuthContext";
export default function LoginScreen() {
const { signIn } = useAuth();
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleLogin = async () => {
try {
await signIn(email, password);
// ← 認証成功後は _layout.tsx の useEffect が自動でリダイレクト
} catch (e) {
console.error(e);
}
};
return (
<View style={{ flex: 1, justifyContent: "center", padding: 24 }}>
<Text style={{ fontSize: 24, marginBottom: 16 }}>ログイン</Text>
<TextInput placeholder="Email" value={email} onChangeText={setEmail} />
<TextInput placeholder="Password" secureTextEntry value={password} onChangeText={setPassword} />
<Button title="ログイン" onPress={handleLogin} />
<Button title="新規登録" onPress={() => router.push("/(auth)/signup")} />
</View>
);
}
Step 4:認証済みグループにタブ + スタックを組み合わせる
// app/(app)/_layout.tsx
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
export default function AppLayout() {
return (
<Tabs>
<Tabs.Screen
name="(home)"
options={{
title: "ホーム",
tabBarIcon: ({ color }) => <Ionicons name="home" color={color} size={24} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "プロフィール",
tabBarIcon: ({ color }) => <Ionicons name="person" color={color} size={24} />,
}}
/>
</Tabs>
);
}
// app/(app)/(home)/_layout.tsx
import { Stack } from "expo-router";
export default function HomeStack() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "ホーム" }} />
<Stack.Screen name="detail/[id]" options={{ title: "詳細" }} />
</Stack>
);
}
タブ内でスタックナビゲーションを実現するには、このように (home) をさらに Stack でラップします。タブを切り替えても各タブのスタック履歴は独立して保持されます。
よくあるハマりポイントと対処法
| 問題 | 原因 | 対処 |
|---|---|---|
| ログイン後に一瞬ログイン画面が見える | isLoading チェックなしでリダイレクト | isLoading が false になるまで SplashScreen.preventAutoHideAsync() で待機 |
| 戻るボタンでログイン画面に戻れる | router.push() を使っている | router.replace() に変更 |
useSegments が空配列を返す | ルートレイアウトより外で呼んでいる | Slot / Stack の内側コンポーネントで呼ぶ |
| タブ内スタックの戻るボタンがタブを切り替える | ネストが不正 | タブ内に Stack レイアウトを正しくネスト |
SplashScreen で初期化待機するコツ
import * as SplashScreen from "expo-splash-screen";
SplashScreen.preventAutoHideAsync();
// AuthProvider 内で isLoading が false になったら
useEffect(() => {
if (!isLoading) SplashScreen.hideAsync();
}, [isLoading]);
これにより、セッション復元が完了する前に画面が表示されてチラつく問題を防げます。
実装パターンの比較
| パターン | メリット | デメリット |
|---|---|---|
useSegments + useRouter(本記事) | シンプル、公式推奨 | 微妙なタイミング問題に注意 |
redirect() in layout(Server Components的) | 宣言的 | Expo Router v3時点では実験的 |
| React Navigation の NavigationContainer 分岐 | 柔軟性が高い | Expo Router と混在しにくい |
Expo Router v3 の安定版では useSegments + useRouter パターンが最も実績があります。
まとめ
Expo Router v3 の認証ルーティング実装のポイントをまとめます。
- ディレクトリ構造:
(auth)/(app)のルートグループで認証境界を明確に分離する - 認証ガード:ルートレイアウトの
useEffect内でuseSegments+router.replace()を使う - ログイン後遷移:
signIn()後はリダイレクトを Context の状態変化に任せる(二重遷移を防ぐ) - タブ内スタック:タブ配下にさらに
Stackをネストしてタブごとの履歴を保持する - チラつき防止:
SplashScreen.preventAutoHideAsync()でセッション復元完了まで待機する
このパターンを土台にすれば、Firebase Auth・Supabase・独自JWTなど任意の認証バックエンドに差し替えられます。useAuth() の中身を変えるだけなので、ルーティングロジックはそのまま使い回せます。