はじめに
Flutterでディープリンクを扱うとき、最初は「URLを受け取って、そのパスに対応する画面を開けばいい」と考えがちです。
ただ、業務アプリを想定すると少し事情が変わります。
たとえば、通知やメールから次のようなURLが開かれるとします。
https://example.com/orders/ABC123
ここでのexample.comは、説明用として予約されているドメイン名です。実装時には自分が管理するドメインへ置き換えます。
/orders/ABC123を注文詳細画面へ結び付けるだけなら簡単です。しかし実際には、その前に考えたいことがあります。
ユーザーはログイン済みなのか。ABC123というIDは妥当なのか。そのデータを現在のユーザーが参照できるのか。データが削除済みならどこへ戻すのか。
そこで今回は、ディープリンクを「外部から画面を指定する仕組み」ではなく、外部から届いた要求をアプリ内の遷移へ変換する仕組みとして組みます。
持ち帰れるものは、go_routerを使ったルーティングコードと、ディープリンクの解析を画面遷移から切り離すための小さなルール集です。
結論
ディープリンクのURLを、そのまま画面の状態として扱わないようにしました。
URL解析 → 認証判定 → 対象確認 → 画面遷移の順に責務を分けます。
外部URLは入力、go_routerは最後の画面遷移、と役割を分けると変更を追いやすくなります。
環境
この記事では、2026年9月時点の次の構成を前提にします。
OS:
Android / iOS
Flutter:
3.47.x
Dart:
Flutter 3.47.x に同梱されるバージョン
go_router:
18.0.1
Flutter 3.47は2026年8月12日に公開されたstableリリースです。
また、go_routerはFlutterチームがメンテナンスしているルーティングパッケージで、2026年9月時点のpub.devでは18.0.1が公開されています。
今回はFlutter標準のディープリンク処理とgo_routerを使い、app_linksなどのサードパーティ製ディープリンク受信プラグインは使いません。
Flutterの公式ドキュメントでも、複雑なディープリンク要件ではRouterまたはgo_routerのようなルーティングパッケージを使う方法が案内されています。
実装
まず、URLと画面を直結させない
最初に今回の流れを図にすると、こうなります。
ここで大事なのは、URLの解析結果をWidgetにしないことです。
たとえば、
/orders/ABC123
を受け取ったからといって、
OrderDetailPage(orderId: 'ABC123')
を即座に作るのではなく、
OpenOrder(orderId: 'ABC123')
という「要求」に変換します。
私はこういう境界では、外から来た値とアプリ内部の意味を一度分けて考えるのが好きです。
URLは外部仕様です。画面構成は内部仕様です。
この2つを同じものとして扱うと、あとでURL体系か画面構成のどちらかを変えたときに、もう片方まで引きずられます。
URLをアプリ内の要求へ変換する
まず、ディープリンクから生成できる要求を定義します。
sealed class DeepLinkIntent {
const DeepLinkIntent();
}
class OpenOrder extends DeepLinkIntent {
const OpenOrder({
required this.orderId,
});
final String orderId;
}
class OpenHome extends DeepLinkIntent {
const OpenHome();
}
class UnsupportedDeepLink extends DeepLinkIntent {
const UnsupportedDeepLink();
}
次にURLを解析します。
DeepLinkIntent parseDeepLink(Uri uri) {
if (uri.scheme != 'https') {
return const UnsupportedDeepLink();
}
if (uri.host != 'example.com') {
return const UnsupportedDeepLink();
}
final segments = uri.pathSegments;
if (segments.isEmpty) {
return const OpenHome();
}
if (segments.length == 2 && segments[0] == 'orders') {
final orderId = segments[1].trim();
if (orderId.isEmpty) {
return const UnsupportedDeepLink();
}
return OpenOrder(orderId: orderId);
}
return const UnsupportedDeepLink();
}
ここではまだ画面遷移をしません。
final intent = parseDeepLink(
Uri.parse('https://example.com/orders/ABC123'),
);
この結果はOpenOrder(orderId: 'ABC123')です。
URLの形式が変わったとしても、アプリ内部ではOpenOrderという意味を維持できます。
たとえば将来、
https://example.com/o/ABC123
へURLを短縮することになっても、parseDeepLink()だけを変更すれば済む形にできます。
IDの形式チェックは解析時に行う
外から受け取った文字列を、そのまま内部のIDとして使うのも避けます。
今回のサンプルでは、注文IDを英数字、ハイフン、アンダースコアだけに限定します。
final orderIdPattern = RegExp(r'^[A-Za-z0-9_-]+$');
DeepLinkIntent parseDeepLink(Uri uri) {
if (uri.scheme != 'https' || uri.host != 'example.com') {
return const UnsupportedDeepLink();
}
final segments = uri.pathSegments;
if (segments.isEmpty) {
return const OpenHome();
}
if (segments.length == 2 && segments[0] == 'orders') {
final orderId = segments[1].trim();
if (!orderIdPattern.hasMatch(orderId)) {
return const UnsupportedDeepLink();
}
return OpenOrder(orderId: orderId);
}
return const UnsupportedDeepLink();
}
もちろん、この正規表現があらゆるアプリに適切という意味ではありません。
UUIDを使っているならUUIDとして検証する。数値IDなら整数として変換する。サーバー側のID仕様に合わせて境界を決めるべきです。
重要なのは、「URLに入っていたから有効なID」とは考えないことです。
go_router側はアプリ内部のURLを担当させる
次にgo_routerを設定します。
dependencies:
flutter:
sdk: flutter
go_router: 18.0.1
ルーターは次のようにします。
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) {
return const HomePage();
},
),
GoRoute(
path: '/login',
builder: (context, state) {
return const LoginPage();
},
),
GoRoute(
path: '/orders/:orderId',
builder: (context, state) {
final orderId = state.pathParameters['orderId'];
if (orderId == null || orderId.isEmpty) {
return const NotFoundPage();
}
return OrderDetailPage(
orderId: orderId,
);
},
),
GoRoute(
path: '/not-found',
builder: (context, state) {
return const NotFoundPage();
},
),
],
);
MaterialApp側ではrouterConfigを渡します。
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
);
}
}
ここまでなら普通のgo_routerです。
違いは、「外から来たURL」と「context.go()へ渡すlocation」の間に変換処理を置くところです。
認証状態を含めて行き先を決める
次に、解析した要求をアプリ内部のlocationへ変換します。
簡略化した認証状態を用意します。
class SessionState {
const SessionState({
required this.isSignedIn,
});
final bool isSignedIn;
}
行き先を決める関数を作ります。
String resolveLocation({
required DeepLinkIntent intent,
required SessionState session,
}) {
return switch (intent) {
OpenHome() => '/',
OpenOrder(:final orderId) when !session.isSignedIn =>
'/login?continue=/orders/$orderId',
OpenOrder(:final orderId) =>
'/orders/$orderId',
UnsupportedDeepLink() =>
'/',
};
}
ここで初めて、
context.go(location);
へ渡します。
呼び出し側をまとめると次のようになります。
void handleDeepLink(
BuildContext context,
Uri uri,
SessionState session,
) {
final intent = parseDeepLink(uri);
final location = resolveLocation(
intent: intent,
session: session,
);
context.go(location);
}
これで責務が3段階になりました。
parseDeepLink()
外部URLを解釈する
resolveLocation()
アプリの状態を踏まえて行き先を決める
context.go()
実際に画面を切り替える
この分離は小さく見えますが、ディープリンクの条件が増えてくると差が出ます。
「ログイン後に続きへ戻る」を文字列のまま信用しない
先ほど、
/login?continue=/orders/ABC123
というURLを作りました。
ここにも注意点があります。
ログイン後にcontinueの値を読み、
context.go(continuePath);
と何でも受け付ける設計にはしたくありません。
今回なら、遷移先として許可するパスを限定します。
String safeContinueLocation(String? value) {
if (value == null) {
return '/';
}
final uri = Uri.tryParse(value);
if (uri == null) {
return '/';
}
if (uri.hasScheme || uri.hasAuthority) {
return '/';
}
final segments = uri.pathSegments;
if (segments.length == 2 && segments[0] == 'orders') {
final orderId = segments[1];
if (orderIdPattern.hasMatch(orderId)) {
return '/orders/$orderId';
}
}
return '/';
}
これなら、
/orders/ABC123
のようにアプリ内部で許可したパスだけを遷移先として採用できます。
一方で、schemeやauthorityを持つURLは/へ戻します。
このように書いておけば、特定の外部サイトを例としてコードに埋め込む必要もありません。
今回のような小さなアプリなら少し大げさに見えるかもしれません。ただ、「外から入ってきた値をどこまで信用するか」という線引きをコードに残しておく意味があります。
データの存在確認は画面側の責務として残す
もう一つ分けたいのが、「形式として正しいID」と「実際に存在するデータ」です。
ABC123
がIDとして妥当でも、その注文が存在するとは限りません。
さらに、存在していても現在のユーザーが参照できるとは限りません。
ここをディープリンクのパーサーで全部調べ始めると、URL解析がAPIアクセスまで抱えてしまいます。
私は次のように境界を置きます。
parseDeepLink
URLとして解釈できるか
resolveLocation
認証など、画面遷移前に分かる状態を確認
OrderDetailPage
対象データを取得し、参照可能か確認
詳細画面では、たとえばRepositoryを経由してデータを読みます。
class OrderDetailPage extends StatelessWidget {
const OrderDetailPage({
super.key,
required this.orderId,
});
final String orderId;
@override
Widget build(BuildContext context) {
return FutureBuilder<Order?>(
future: orderRepository.findReadableOrder(orderId),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
final order = snapshot.data;
if (order == null) {
return const NotFoundPage();
}
return OrderPage(order: order);
},
);
}
}
ここでのfindReadableOrder()は説明用の抽象的なメソッドです。実際にはAPIやRepositoryの設計に合わせます。
大事なのは、クライアント側でIDの形式を検証したからといって、認可まで完了したことにはしない点です。
参照権限の最終判定をサーバー側で行う必要があるシステムなら、クライアントの画面遷移だけでアクセス制御を成立させないようにします。
Android App Linksを設定する
AndroidではAndroidManifest.xmlに対象ドメインを設定します。
Flutter公式ドキュメントのApp Links設定では、android:autoVerify="true"を付けたintent-filterを利用します。
概念としては次の形です。
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="example.com" />
</intent-filter>
さらにWebサーバー側では、
https://example.com/.well-known/assetlinks.json
にDigital Asset Linksの設定が必要です。
ここにはAndroidアプリのpackage nameや署名証明書のSHA-256フィンガープリントなどを設定します。
値はアプリごとに異なるので、この記事では架空の値を載せません。
iOS Universal Linksを設定する
iOSではAssociated Domainsを設定し、
applinks:example.com
のように対象ドメインを登録します。
Web側には、
https://example.com/.well-known/apple-app-site-association
など、Appleが定める場所へapple-app-site-associationファイルを配置します。
こちらもTeam IDやBundle IDなどアプリ固有の情報が必要です。
AndroidもiOSも、Flutter側のコードだけ書けば終わりではありません。
ディープリンクはアプリだけの機能というより、Webドメイン、OS、アプリの3者で成立する仕組みとして見たほうが整理しやすいです。
ハマりどころ
Flutter標準処理とプラグインを二重に使わない
現在のFlutterでは、標準のディープリンク処理が有効になっています。
一方、app_linksなどのサードパーティ製プラグインでディープリンクを受ける場合、Flutter公式ドキュメントでは標準ハンドラーを無効にするよう案内されています。
AndroidならAndroidManifest.xmlで、
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
iOSならInfo.plistで、
<key>FlutterDeepLinkingEnabled</key>
<false/>
とする形です。
今回は標準処理を使うので、この無効化はしません。
「念のためプラグインでも受ける」という構成にすると、どちらがURLを処理しているのか分かりにくくなります。受信経路は最初に一つへ決めたほうが追いやすいです。
アプリ起動中と停止中を分けて考える
ディープリンクは「アプリを起動するURL」だけではありません。
アプリがすでに動いているところへ新しいURLが届く場合もあります。
Flutter公式ドキュメントでも、アプリの起動状態とNavigatorまたはRouterの利用によってディープリンクの扱われ方が異なることが示されています。
そのためテストでは、少なくとも次を分けます。
[ ] アプリ未起動からリンクを開く
[ ] アプリ起動中にリンクを開く
[ ] ログイン済みで開く
[ ] 未ログインで開く
[ ] 正しいIDで開く
[ ] 不正なIDで開く
[ ] 存在しないIDで開く
[ ] 対応していないパスを開く
[ ] 許可していないschemeやhostを渡す
ディープリンクは正常系だけ確認すると、かなり簡単に見えます。
むしろ境界値を入れたときに、どこへ戻すかを先に決めておくほうが重要です。
404を「アプリが壊れた状態」にしない
リンク先のデータが削除されることはあります。
古いメールや通知から時間が経ってリンクが開かれる可能性もあります。
そのため、
throw Exception('Order not found');
だけで終わらせず、ユーザーが次の操作へ戻れる画面を用意します。
class NotFoundPage extends StatelessWidget {
const NotFoundPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('対象のデータを表示できません'),
const SizedBox(height: 16),
FilledButton(
onPressed: () => context.go('/'),
child: const Text('ホームへ戻る'),
),
],
),
),
);
}
}
「URLとして正しかったが、今は表示できない」は普通に起こり得る状態として扱います。
DevToolsで関連付けまで確認する
ディープリンクが開かないとき、Dartコードだけを眺め続けても原因が見つからないことがあります。
Flutter DevToolsにはDeep Link Validatorがあり、Flutter 3.27以降ではAndroidとiOSの両方に対応しています。
Web側の関連付けやManifestなども含めて確認できるため、
OSがアプリへ渡していない
のか、
Flutterまで届いた後のルーティングがおかしい
のかを切り分ける材料になります。
ディープリンクは複数レイヤーをまたぐので、「どの地点までURLが届いているか」を順番に確認するのが近道です。
まとめ
Flutterでディープリンクを作るとき、URLと画面を一対一で結ぶだけならgo_routerでかなり簡潔に書けます。
ただ、業務アプリではその間に認証、ID検証、データの存在確認、参照権限などが入ります。
そこで今回は、
外部URL
↓
DeepLinkIntent
↓
アプリ状態を使った遷移判定
↓
go_router
↓
データ取得・認可
↓
画面
という境界にしました。
特に残しておきたいルールは次の5つです。
- 外部URLをそのまま画面の状態にしない
- scheme、host、path、IDを境界で検証する
- 未ログイン時の戻り先も無条件には信用しない
- クライアントのURL検証とサーバー側の認可を混同しない
- 存在しない古いリンクを通常の状態として扱う
ディープリンクは「特定画面を開く便利機能」に見えますが、実際にはアプリの外から内部状態へ値を持ち込む経路でもあります。
だからこそ、画面遷移を増やす前に、どこで外部入力をアプリ内部の意味へ変換するのかを決めておく。この一段を置くだけで、URL体系や認証条件が増えたときにも追いやすい構造になります。

