公開日 2026-07-21

Flutterでgo_routerの認証ガードを実装する(redirect最小構成)

Flutterで go_router の redirect を使い、未ログイン時のログイン画面への分岐、ログイン後の戻り先保持、詳細 route への direct access を最小構成で確認できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. redirect で何を判定するかを先に掴む
  6. 3. プロジェクトを作成し、go_router を追加する
  7. 3-1. Flutter の環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 3-4. go_router を追加する
  11. 4. lib/main.dart に認証ガードの最小構成をまとめる
  12. コードのポイント
  13. 5. flutter run で認証前後の分岐を確認する
  14. 6. redirect コールバックの見方を整理する
  15. 7. まとめ

Flutterのルーティング入門(Navigator と go_router の使い分け) の次に詰まりやすいのが、未ログイン時の route 分岐です。Flutterの状態管理入門(Riverpod最小構成) で状態の置き場を見たあとでも、go_routerredirect に何を書けばよいかは別の壁になります。この記事では go_routerredirectrefreshListenable に絞り、未ログインなら /login へ寄せ、ログイン後は元の route へ戻す最小構成を lib/main.dart 1 ファイルで確認します。

1. ゴールと非対象

対象読者

  • Flutter のルーティング入門を終え、go_router の基本設定までは見た人
  • 未ログイン時にログイン画面へ寄せる処理を、最小構成で先に固めたい人
  • 後続の設定保存やログイン状態保持へ進む前に、認証前後の route 分岐だけを理解したい人

この記事で到達する状態

  • GoRouterredirectrefreshListenable を設定できる
  • 未ログイン時に /orders/orders/:orderId から /login へ分岐できる
  • ログイン後に from クエリを見て元の route へ戻せる
  • deep link 相当で詳細 route を直接開いたときの挙動を説明できる

非対象

  • JWT や secure storage によるログイン状態保持
  • 認証 API 呼び出し
  • Riverpod と go_router の本格統合
  • ShellRoute や権限ロールごとの分岐
  • Android の App Links / iOS Universal Links の設定

今回は route 分岐の入口に絞ります。ログイン状態は in-memory で持ち、アプリ再起動で未ログインへ戻る前提です。永続化は後続の記事へ回し、まずは redirect の見方を固めます。

2. redirect で何を判定するかを先に掴む

今回の流れは次の通りです。

flowchart TD
  A["ユーザーが route を開く"] --> B{ログイン済みか}
  B -->|いいえ| C{開こうとしたのは /login か}
  C -->|はい| D["/login をそのまま表示"]
  C -->|いいえ| E["/login?from=元の route へ redirect"]
  B -->|はい| F{今いる route は /login か}
  F -->|いいえ| G["要求した route をそのまま表示"]
  F -->|はい| H{from があるか}
  H -->|ある| I["from に戻す"]
  H -->|ない| J["/orders に戻す"]

見る場所は 3 つです。

  • 未ログインかどうか
  • いま開こうとしている route が /login かどうか
  • ログイン後に戻す先として from を持っているかどうか

redirect は画面を組み立てる場所ではありません。この route を通してよいか、それとも別の route へ寄せるかを判定する場所です。ここを先に図で押さえておくと、builder と役割が混ざりにくくなります。

3. プロジェクトを作成し、go_router を追加する

3-1. Flutter の環境構築がまだなら先に済ませる

環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。

3-2. Flutter プロジェクトを作成する

次のコマンドでプロジェクトを作成します。

flutter create my_auth_guard_app
cd my_auth_guard_app

3-3. エミュレーターを起動する

利用可能なエミュレーター一覧を確認します。

flutter emulators

表示された ID を指定して起動します。

flutter emulators --launch <emulator_id>

3-4. go_router を追加する

プロジェクト直下で次のコマンドを実行します。

flutter pub add go_router

今回は go_router だけを追加します。ログイン状態は ChangeNotifier で in-memory 管理に絞るため、状態管理ライブラリを増やさなくても認証ガードの流れは確認できます。

4. lib/main.dart に認証ガードの最小構成をまとめる

lib/main.dart は次の内容で作成します。全体をコピーしたあと、下の「コードのポイント」で重要な箇所を確認します。

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() {
  runApp(const MyApp());
}

final SessionController sessionController = SessionController();

final GoRouter router = GoRouter(
  initialLocation: '/orders',
  refreshListenable: sessionController,
  redirect: (BuildContext context, GoRouterState state) {
    final bool loggedIn = sessionController.isLoggedIn;
    final bool isLoginRoute = state.matchedLocation == '/login';

    if (!loggedIn && !isLoginRoute) {
      return Uri(
        path: '/login',
        queryParameters: <String, String>{
          'from': state.uri.toString(),
        },
      ).toString();
    }

    if (loggedIn && isLoginRoute) {
      final String from = _safeFrom(state.uri.queryParameters['from']);
      return from;
    }

    return null;
  },
  routes: <RouteBase>[
    GoRoute(
      path: '/login',
      builder: (BuildContext context, GoRouterState state) {
        final String from = _safeFrom(state.uri.queryParameters['from']);
        return LoginPage(from: from);
      },
    ),
    GoRoute(
      path: '/orders',
      builder: (BuildContext context, GoRouterState state) {
        return const OrdersPage();
      },
      routes: <RouteBase>[
        GoRoute(
          path: ':orderId',
          builder: (BuildContext context, GoRouterState state) {
            final String orderId = state.pathParameters['orderId']!;
            final OrderSummary order = findOrder(orderId);
            return OrderDetailPage(order: order);
          },
        ),
      ],
    ),
  ],
);

String _safeFrom(String? from) {
  if (from == null || from.isEmpty || from == '/login') {
    return '/orders';
  }
  return from;
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'go_router auth guard sample',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
      ),
      routerConfig: router,
    );
  }
}

class SessionController extends ChangeNotifier {
  bool _isLoggedIn = false;

  bool get isLoggedIn => _isLoggedIn;

  void login() {
    if (_isLoggedIn) {
      return;
    }
    _isLoggedIn = true;
    notifyListeners();
  }

  void logout() {
    if (!_isLoggedIn) {
      return;
    }
    _isLoggedIn = false;
    notifyListeners();
  }
}

class OrderSummary {
  const OrderSummary({
    required this.id,
    required this.customer,
    required this.status,
    required this.total,
  });

  final String id;
  final String customer;
  final String status;
  final int total;
}

const List<OrderSummary> demoOrders = <OrderSummary>[
  OrderSummary(id: '1001', customer: '東京商事', status: '出荷待ち', total: 12000),
  OrderSummary(id: '2002', customer: '大阪物産', status: '確認待ち', total: 18400),
  OrderSummary(id: '3003', customer: '名古屋販売', status: 'ピッキング中', total: 9600),
];

OrderSummary findOrder(String id) {
  return demoOrders.firstWhere(
    (OrderSummary order) => order.id == id,
    orElse: () => OrderSummary(
      id: id,
      customer: '不明な取引先',
      status: '確認対象',
      total: 0,
    ),
  );
}

class LoginPage extends StatelessWidget {
  const LoginPage({super.key, required this.from});

  final String from;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('ログイン')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: <Widget>[
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    '未ログイン時は protected route からここへ寄せます。',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 8),
                  Text('ログイン後の戻り先: $from'),
                  const SizedBox(height: 16),
                  FilledButton.icon(
                    onPressed: sessionController.login,
                    icon: const Icon(Icons.login),
                    label: const Text('ログインする'),
                  ),
                  const SizedBox(height: 12),
                  OutlinedButton.icon(
                    onPressed: () {
                      context.go('/orders/2002');
                    },
                    icon: const Icon(Icons.link),
                    label: const Text('未ログインのまま /orders/2002 を試す'),
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: const <Widget>[
                  Text('このサンプルで確認すること'),
                  SizedBox(height: 8),
                  Text('1. 初回起動で /orders へ入ろうとしても /login に寄る'),
                  Text('2. ログイン後は from に入っていた route へ戻る'),
                  Text('3. in-memory 管理なのでアプリ再起動では未ログインに戻る'),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class OrdersPage extends StatelessWidget {
  const OrdersPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('受注一覧'),
        actions: <Widget>[
          TextButton.icon(
            onPressed: sessionController.logout,
            icon: const Icon(Icons.logout),
            label: const Text('ログアウト'),
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: <Widget>[
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: const <Widget>[
                  Text('この画面はログイン済みのときだけ表示されます。'),
                  SizedBox(height: 8),
                  Text('詳細画面も同じ認証ガードの対象です。'),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          for (final OrderSummary order in demoOrders)
            Card(
              child: ListTile(
                title: Text('受注 ${order.id}'),
                subtitle: Text('${order.customer} / ${order.status}'),
                trailing: const Icon(Icons.chevron_right),
                onTap: () {
                  context.push('/orders/${order.id}');
                },
              ),
            ),
        ],
      ),
    );
  }
}

class OrderDetailPage extends StatelessWidget {
  const OrderDetailPage({super.key, required this.order});

  final OrderSummary order;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('受注詳細 ${order.id}')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: <Widget>[
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    '受注 ${order.id}',
                    style: Theme.of(context).textTheme.titleLarge,
                  ),
                  const SizedBox(height: 12),
                  Text('取引先: ${order.customer}'),
                  const SizedBox(height: 8),
                  Text('ステータス: ${order.status}'),
                  const SizedBox(height: 8),
                  Text('合計金額: ${order.total} 円'),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          FilledButton.icon(
            onPressed: () {
              context.pop();
            },
            icon: const Icon(Icons.arrow_back),
            label: const Text('一覧へ戻る'),
          ),
        ],
      ),
    );
  }
}

コードのポイント

① refreshListenable でログイン状態の変化を router に伝える

final GoRouter router = GoRouter(
  initialLocation: '/orders',
  refreshListenable: sessionController,
  redirect: ...,
);

refreshListenablesessionController を渡すと、login()logout() の中で notifyListeners() が呼ばれるたびに redirect が再評価されます。これがないと、ログイン状態が変わっても router が反応しません。

② redirect の判定は3条件だけ

redirect: (BuildContext context, GoRouterState state) {
  final bool loggedIn = sessionController.isLoggedIn;
  final bool isLoginRoute = state.matchedLocation == '/login';

  if (!loggedIn && !isLoginRoute) {
    return Uri(
      path: '/login',
      queryParameters: <String, String>{'from': state.uri.toString()},
    ).toString();
  }
  if (loggedIn && isLoginRoute) {
    return _safeFrom(state.uri.queryParameters['from']);
  }
  return null;
},

null を返した route だけが実際に表示されます。redirect は認証処理をする場所ではなく、route の通行判定だけをする場所です。

③ _safeFrom で /login への無限ループを防ぐ

String _safeFrom(String? from) {
  if (from == null || from.isEmpty || from == '/login') {
    return '/orders';
  }
  return from;
}

from/login が入っていると、ログイン後に再び /login へ戻るループになります。_safeFrom でガードし、フォールバックを /orders に固定します。

LoginPage/orders/2002 を開くボタンを置いているのは、deep link 相当の挙動をアプリ内だけで再現するためです。OS の deep link 設定は扱いません。それでも「詳細 route を直接開く -> 未ログインなら login に寄る -> ログイン後に詳細へ戻る」という流れなら、この 1 つで追えます。

5. flutter run で認証前後の分岐を確認する

次のコマンドでアプリを起動します。

flutter run

初回起動では /orders を開こうとしても、まずログイン画面へ寄ります。画面内の戻り先表示が /orders になっていれば、from に元の route が入っています。

初回起動で /login?from=/orders へ redirect されたログイン画面

起動後は次の順で確認します。

  1. 初回起動で受注一覧ではなくログイン画面が開く
  2. 画面内の「ログイン後の戻り先」が /orders になっている
  3. 「ログインする」を押すと受注一覧へ進む
  4. 一覧から任意の受注を開ける
  5. ログイン画面に戻り、「未ログインのまま /orders/2002 を試す」を押す
  6. 戻り先が /orders/2002 に変わる
  7. その状態でログインすると、一覧ではなく受注詳細 2002 へ進む

deep link 相当の確認では、URL 表示そのものより、未ログインで protected route を開いたあとも元の route を保ったまま login を挟めるかを見ます。from/orders/2002 が入っていれば、ログイン後に詳細へ戻せます。

未ログインのまま詳細 route を試すと、ログイン画面の戻り先表示だけが /orders/2002 に変わります。

未ログインで /orders/2002 を開き、戻り先が /orders/2002 になったログイン画面

その状態でログインすると、一覧ではなく受注詳細 2002 に戻ります。

ログイン後に /orders/2002 へ戻った受注詳細画面

6. redirect コールバックの見方を整理する

今回の redirect は次の 3 条件だけです。

条件返り値意味
未ログインかつ /login 以外を開こうとした/login?from=元の route一度ログイン画面へ寄せる
ログイン済みかつ /login を開いたfrom または /ordersログイン後に login 画面へ留まらせない
それ以外nullその route をそのまま表示する

redirect が担うのは認証処理ではなく、route を通してよいかどうかの判定です。ログイン状態そのものは sessionController.isLoggedIn にあり、永続化へ置き換えたいときは後でその保存先を差し替えます。

refreshListenable が必要になる理由も同じです。GoRouter は route 変更時だけでなく、ログイン状態が変わったときにも redirect を再評価する必要があります。sessionController.login()logout()notifyListeners() が走ると、その変化を受けて route を見直せます。

fromqueryParameters に入れているのは、元の route を文字列で持ち運ぶためです。今回のように /orders/2002 をそのまま戻せるので、一覧でも詳細でも同じロジックで扱えます。ログイン後の戻り先を個別画面ごとに if 文で分けなくて済む点が、この構成の利点です。

このサンプルが意図的に外している要素もあります。

  • アプリ再起動後のログイン状態復元
  • API から受け取ったトークンの保持
  • 権限別の route 分岐

これらを一度に足すと、redirect 自体の役割が見えにくくなります。まずは route ガードだけを成立させ、次に保存先や API を差し替える順番で追うほうが理解しやすくなります。

7. まとめ

go_router の認証ガードで先に固めるべきなのは、認証基盤全体ではなく route 分岐です。redirect で未ログイン時の行き先を決め、refreshListenable でログイン状態の変化を再評価し、from で元の route を保持する。この 3 点がそろえば、一覧でも詳細でも同じ考え方で guard を掛けられます。

次に手を入れるなら、ログイン状態の保存先です。secure storage や JWT 保存を足す段階でも、今回の redirect はそのまま土台として使えます。保存先の整理から進めたいときは、SharedPreferences と secure storage の使い分け記事が次の入口です。

シリーズ 22/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成) 現在の記事
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)