「Expo Routerで特定の画面をURLから開きたい」「アプリ終了時は動くのに、起動中は反応しない」「未ログイン時の遷移はどう設計すればよい?」
このような疑問を解決します。
私もReact Nativeの副業案件で、Expo Router 5以降への移行と、認証状態を含むディープリンク処理の実装に関わりました。本記事では、その経験から得た実装の考え方とハマりポイントを、最小構成のサンプルで解説します。
本記事でわかることは以下の通りです。
- Expo Routerでディープリンクを設定する方法
- コールドスタートとホットスタートの違い
- 認証が必要な画面を
Stack.Protectedで守る方法 - 旧URLを
+native-intent.tsxで変換する方法 - iOS/Androidで確認すべきテスト項目
実装の前提
本記事のコードは、Expo Router v5以降の公式ドキュメントに沿った最小構成です。構文とURL変換ロジックは確認済みですが、掲載コード一式でのiOS/Android実機動作は未確認です。
公式資料の確認日: 2026年9月23日
先に結論:通常はExpo Routerに任せればよい
Expo Routerは、ファイル構成とURLを対応させてディープリンクを自動処理します。
たとえば、src/app/(app)/products/[id].tsxがあれば、sampleapp://products/42から商品ID42の画面を開けます。ルートグループの(app)はURLへ含まれません。通常はLinking.getInitialURL()やURLイベントを自分で処理する必要はありません。
追加実装が必要になるのは、主に次のケースです。
- 未ログインならサインイン画面を表示したい
- 旧URLや外部サービスのURLを現在のルートへ変換したい
- 不正なURLを専用のエラー画面へ送りたい
- ディープリンク先から戻る画面も用意したい
- 遷移後にアクセス解析などの処理を行いたい
基本のルーティングはExpo Routerへ任せ、認証はStack.Protected、URL変換は+native-intent.tsx、戻る履歴はanchorへ分けると整理しやすくなります。
コールドスタートとホットスタートの違い
ディープリンクで最初に理解したいのが、アプリの起動状態による違いです。
| 状態 | 意味 | React Native Linkingを直接使う場合 |
|---|---|---|
| コールドスタート | アプリが終了しており、URLから新しく起動する | Linking.getInitialURL() |
| ホットスタート | アプリが起動中、またはバックグラウンドから復帰する | Linking.addEventListener("url", ...) |

React NativeのLinkingを直接使う場合は、初期URLとURLイベントの両方を扱う必要があります。どちらか一方だけでは、アプリの状態によってURLを取りこぼします。
一方、Expo Routerではルーターが両方の状態を処理します。公式ドキュメントでも、Expo Routerを使う場合は手動のURL処理を省略できると説明されています。
この違いを知らずにExpo Routerと手動リスナーの両方から画面遷移すると、二重遷移や履歴の崩れが起きやすくなります。
Expo Routerでディープリンクを実装する
ここでは、次のURLから商品詳細画面を開く最小構成を作ります。
sampleapp://products/42?from=mail
app.config.tsにschemeを設定する
import type { ConfigContext, ExpoConfig } from "expo/config";
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: "DeepLinkSample",
slug: "deep-link-sample",
scheme: "sampleapp",
ios: {
...config.ios,
bundleIdentifier: "com.example.deeplinksample",
},
android: {
...config.android,
package: "com.example.deeplinksample",
},
});
schemeを追加・変更した後は、JavaScriptの再読み込みだけでは反映されません。Development Buildを作り直してください。
URLに対応するファイルを作る
認証が必要な画面を(app)グループにまとめる場合、ファイル構成は次のようになります。丸括弧で囲んだルートグループ名はURLに含まれません。
src/app/
├─ _layout.tsx
├─ sign-in.tsx
├─ link-error.tsx
├─ +not-found.tsx
├─ +native-intent.tsx
└─ (app)/
├─ _layout.tsx
├─ index.tsx
└─ products/
└─ [id].tsx
src/app/(app)/products/[id].tsxでは、パスとクエリパラメータをuseLocalSearchParams()で受け取ります。
import { useLocalSearchParams } from "expo-router";
import { Text, View } from "react-native";
export default function ProductDetailScreen() {
const { id, from } = useLocalSearchParams<{
id: string;
from?: string;
}>();
return (
<View>
<Text>商品ID: {id}</Text>
<Text>流入元: {from ?? "unknown"}</Text>
</View>
);
}
これで、URLのproducts/42とファイルのproducts/[id].tsxが対応します。
ディープリンクの値は外部入力です。実際にAPIへ渡す前に、IDの形式、許可するパス、クエリパラメータの値を検証してください。
認証が必要な画面をStack.Protectedで守る
Expo Router v5以降では、Stack.Protectedのguardで画面へアクセスできる条件を指定できます。
import { Stack } from "expo-router";
import { useSession } from "@/contexts/session";
export default function RootLayout() {
const { isLoading, session } = useSession();
if (isLoading) return null;
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={Boolean(session)}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={!session}>
<Stack.Screen name="sign-in" />
</Stack.Protected>
</Stack>
);
}
未ログインの状態でsampleapp://products/42を開くと、保護された(app)グループではなく、利用可能なサインイン画面へ移動します。
この構成だけでは、ログイン後に元の/products/42へ自動で戻るわけではありません。認証状態が切り替わると、通常は(app)グループの先頭画面へ移動します。
ただし、Protected Routesはクライアント側の画面制御です。API側の認証・認可を置き換えるものではありません。
認証の読み込み完了を待つ
起動直後は、端末に保存したセッションの復元に時間がかかることがあります。読み込み中の状態を未ログインと判断すると、一度サインイン画面へ移動してから目的画面へ戻るちらつきが起きます。
認証状態が確定するまではローディング表示またはSplash Screenを維持し、確定後にguardを評価します。
ログイン後に元のURLへ戻す場合
元のディープリンク先を保持する方法は、サインイン画面をモーダル表示して背後のルートを残す方法と、検証済みの遷移先をアプリ側で保存する方法があります。
保存したURLをそのまま信用せず、アプリ内で許可したパスだけへ戻すようにしてください。
旧URLを+native-intent.tsxで変換する
外部サービスや旧バージョンのURLが、現在のファイルルートと一致しない場合があります。
たとえば、次の旧URLを受け取ったとします。
sampleapp://legacy-product?id=42
src/app/+native-intent.tsxのredirectSystemPath()で、現在の/products/42へ変換できます。
type RedirectSystemPathOptions = {
path: string;
initial: boolean;
};
export function redirectSystemPath({ path }: RedirectSystemPathOptions) {
try {
// pathは正しいURLとは限らないため、基準URLとtry/catchを用意する。
const url = new URL(path, "sampleapp://app");
if (url.hostname === "legacy-product") {
const id = url.searchParams.get("id");
return id ? `/products/${encodeURIComponent(id)}` : "/link-error";
}
return path;
} catch {
return "/link-error";
}
}
redirectSystemPath()へ渡されるpathは、正しいURLである保証がありません。公式例と同様にtry/catchで囲み、失敗時に表示するlink-error.tsxも用意します。
+native-intent.tsxはReactコンポーネントの外で実行されるため、認証状態や現在の画面へアクセスできません。URLの変換と認証判定は分けて実装します。
ディープリンク先から戻れるようにする
ディープリンクで詳細画面を直接開くと、戻る画面が履歴にないことがあります。
現行のExpo Routerでは、レイアウトにanchorを設定します。
import { Stack } from "expo-router";
export const unstable_settings = {
anchor: "index",
};
export default function AppLayout() {
return <Stack />;
}
src/app/(app)/_layout.tsxへ設定すると、/products/42へ直接開いた場合でも、同じスタックのindexを戻り先にできます。
別のスタックから対象レイアウト内の画面へ直接遷移し、そのレイアウトのアンカーを履歴へ追加したい場合は、withAnchorを指定します。
import { useRouter } from "expo-router";
import { Button } from "react-native";
export default function ProductLinkButton() {
const router = useRouter();
return (
<Button
title="商品42を開く"
onPress={() => router.push("/products/42", { withAnchor: true })}
/>
);
}
実案件でExpo Router 5以降へ対応した当時は、unstable_settings.initialRouteNameを使用していました。現在の公式ドキュメントではinitialRouteNameは非推奨となり、anchorの使用が案内されています。本記事とサンプルは現行の書き方へ更新しています。
iOS/Androidでディープリンクをテストする
Expo公式ドキュメントでは、npx uri-schemeを使ったテスト方法が案内されています。
npx uri-scheme open "sampleapp://products/42?from=mail" --ios
npx uri-scheme open "sampleapp://products/42?from=mail" --android
カスタムスキームを変更した場合は、先にDevelopment Buildを作り直します。

最低限、次の組み合わせを確認します。
| No. | アプリ状態 | 認証状態 | URL | 期待結果 |
|---|---|---|---|---|
| 1 | 終了 | ログイン済み | 正常 | 対象画面が1回だけ開く |
| 2 | 起動中 | ログイン済み | 正常 | 現在画面から対象画面へ移動する |
| 3 | バックグラウンド | ログイン済み | 正常 | 復帰後に対象画面が開く |
| 4 | 終了 | 未ログイン | 正常 | サインイン後に対象画面が開く |
| 5 | 起動中 | 認証切れ | 正常 | 再認証後に対象画面が開く |
| 6 | 終了/起動中 | 任意 | 存在しないパス | Not Found画面を表示する |
| 7 | 終了/起動中 | 任意 | 不正なID | APIを呼ばず安全な画面へ送る |
| 8 | 終了/起動中 | 任意 | 旧URL | 現在のルートへ1回だけ変換する |
※ No.4、5は、元の遷移先を保持し、認証後に復元する処理を実装した場合の期待結果です。掲載しているStack.Protectedの最小構成だけでは、認証後に(app)グループの先頭画面へ移動します。
コールドスタートだけでなく、アプリ起動中とバックグラウンド復帰も確認するのがポイントです。
よくあるハマりポイント
アプリは開くが対象画面へ移動しない
次の項目を順番に確認します。
- URLと
src/app配下のファイルパスが一致しているか scheme変更後にDevelopment Buildを作り直したか- Expo Routerとは別に手動の
Linkingリスナーで遷移していないか - 未ログイン時にProtected Routesで別画面へ送られていないか
- 旧URLなら
+native-intent.tsxの変換条件に一致しているか
Expo Goだけで本番用URLを確認している
Expo Goの受信URLはexp://.../--/path形式で、製品版のカスタムスキームとは異なります。Expo公式も、リンク機能の確認にはDevelopment Buildを推奨しています。
認証コールバックなど安定したURLが必要な機能は、Development Buildまたは製品に近いビルドで確認します。
Expo Routerと手動リスナーが競合する
Expo Routerを使っているアプリで、同じURLをLinking.addEventListener()でもrouter.push()すると、二重遷移する可能性があります。
React NativeをExpo Routerなしで構成する場合は、コールドスタートにLinking.getInitialURL()、ホットスタートにLinking.addEventListener("url", ...)を使います。Expo Routerを使う場合は、まずルーター標準の仕組みで表現できないか確認してください。
カスタムスキームだけで製品運用する
カスタムスキームは導入しやすい一方、同じスキームを別アプリが宣言でき、アプリ未インストール時のWeb遷移にも弱い仕組みです。
製品では、通常のHTTPS URLを使うiOS Universal Links/Android App Linksも検討します。
よくある質問
Expo RouterでもgetInitialURLは必要ですか?
通常は不要です。Expo Routerがファイルルートへのディープリンクを自動処理します。手動で受け取るのは、Expo Routerを使わない構成や、標準機能では扱えない特別な連携がある場合です。
+native-intent.tsxは認証判定にも使えますか?
おすすめしません。+native-intent.tsxではReactのContextへアクセスできないため、URL変換だけを担当させ、認証はProtected Routesやレイアウトで判定します。
カスタムスキームとUniversal Linksはどちらを使いますか?
開発中の最小確認にはカスタムスキームが簡単です。一般ユーザー向けの製品では、アプリ未インストール時にWebサイトへフォールバックできるUniversal Links/Android App Linksも検討します。
まとめ
Expo Routerのディープリンクは、ファイル構成に合わせた自動ルーティングを使うのが基本です。
実装時の役割は次のように分けます。
- 通常のURLと画面の対応: Expo Routerのファイルルーティング
- 認証が必要な画面:
Stack.Protected - 旧URLや外部サービスURLの変換:
+native-intent.tsx - ディープリンク先からの戻り先:
anchor - コールド/ホットの追加テスト: iOS/AndroidのDevelopment Buildと実機
実装後は「リンクからアプリが開いた」で終わらせず、起動状態、認証状態、正常/不正URLを組み合わせて確認することが重要です。