💡 Tips

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 チェックなしでリダイレクトisLoadingfalse になるまで 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 パターンが最も実績があります。

📚 おすすめ書籍

React Native & Expo 実践開発ガイド

Expo Routerを含むモバイル開発の体系的な学習に最適

Amazonで見る →

まとめ

Expo Router v3 の認証ルーティング実装のポイントをまとめます。

  1. ディレクトリ構造(auth) / (app) のルートグループで認証境界を明確に分離する
  2. 認証ガード:ルートレイアウトの useEffect 内で useSegments + router.replace() を使う
  3. ログイン後遷移signIn() 後はリダイレクトを Context の状態変化に任せる(二重遷移を防ぐ)
  4. タブ内スタック:タブ配下にさらに Stack をネストしてタブごとの履歴を保持する
  5. チラつき防止SplashScreen.preventAutoHideAsync() でセッション復元完了まで待機する

このパターンを土台にすれば、Firebase Auth・Supabase・独自JWTなど任意の認証バックエンドに差し替えられます。useAuth() の中身を変えるだけなので、ルーティングロジックはそのまま使い回せます。