Flutterのルーティング入門(Navigator と go_router の使い分け) の次に詰まりやすいのが、未ログイン時の route 分岐です。Flutterの状態管理入門(Riverpod最小構成) で状態の置き場を見たあとでも、go_router の redirect に何を書けばよいかは別の壁になります。この記事では go_router の redirect と refreshListenable に絞り、未ログインなら /login へ寄せ、ログイン後は元の route へ戻す最小構成を lib/main.dart 1 ファイルで確認します。
1. ゴールと非対象
対象読者
- Flutter のルーティング入門を終え、
go_routerの基本設定までは見た人 - 未ログイン時にログイン画面へ寄せる処理を、最小構成で先に固めたい人
- 後続の設定保存やログイン状態保持へ進む前に、認証前後の route 分岐だけを理解したい人
この記事で到達する状態
GoRouterにredirectとrefreshListenableを設定できる- 未ログイン時に
/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: ...,
);
refreshListenable に sessionController を渡すと、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 が入っています。
起動後は次の順で確認します。
- 初回起動で受注一覧ではなくログイン画面が開く
- 画面内の「ログイン後の戻り先」が
/ordersになっている - 「ログインする」を押すと受注一覧へ進む
- 一覧から任意の受注を開ける
- ログイン画面に戻り、「未ログインのまま /orders/2002 を試す」を押す
- 戻り先が
/orders/2002に変わる - その状態でログインすると、一覧ではなく受注詳細
2002へ進む
deep link 相当の確認では、URL 表示そのものより、未ログインで protected route を開いたあとも元の route を保ったまま login を挟めるかを見ます。from に /orders/2002 が入っていれば、ログイン後に詳細へ戻せます。
未ログインのまま詳細 route を試すと、ログイン画面の戻り先表示だけが /orders/2002 に変わります。
その状態でログインすると、一覧ではなく受注詳細 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 を見直せます。
from を queryParameters に入れているのは、元の 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 の使い分け記事が次の入口です。