Next.js の Parallel Routes と Intercepting Routes を理解したい

Next.js の公式 Doc に Parallel Routes の例として登場する Modal の例がよくわからなかったので自分で実装してみて理解を深めていく。

project setup
とりあえず、create-next-app で空の project 作る。
npx create-next-app@latest

Parallel Routes & Intercepting Routes の Modal example
公式 Doc に沿って実装してみる。

Login Page 作る
app/login/page.tsx
用意。
const LoginPage = () => {
return (
<div>
<h1 className='text-4xl font-bold'>Login</h1>
</div>
);
};
export default LoginPage;
/login で login page が表示される。

@auth slot 作成
app/@auth/default.tsx
を用意。
公式 Doc だと null を return しているが、一旦 UI に表示してみたいのでこうする↓
const AuthSlotDefault = () => {
return (
<div>
<h1 className='text-4xl font-bold'>@auth slot default</h1>
</div>
);
};
export default AuthSlotDefault;

layout に @auth slot 出してみる
export default function RootLayout({
children,
+ auth,
}: Readonly<{
children: React.ReactNode;
+ auth: React.ReactNode;
}>) {
return (
<html lang='en'>
<body className={inter.className}>
{children}
+ <div className='mt-5 border p-5'>
+ <div>Auth Slot comes here↓↓↓↓</div>
+ <div>{auth}</div>
+ </div>
</body>
</html>
);
}
/
にアクセス。
@auth slot の default.tsx の内容出てきてないぞ...

一度 dev server 止めて、再度スタートしたら出た↓

/login
を開く
app/login/page.tsx
app/@auth/defafult.tsx
どちらの内容も表示されていることがわかる。

Parallel Routes 一旦まとめ
- Parallel Routes は複数の "Page 領域" 的なものを一つの Layout に共存させるようなもの。
- Parallel Routes に現在の path に match する route の page.tsx が存在しない場合、default.tsx が利用される。

Intercepting Routes 動作確認
公式 Doc にはないが、動作を確認したいので root に /login
の intercepting route を作ってみる。
app/(.)login/page.tsx
const InterceptingLogin = () => {
return (
<div>
<h1 className='text-4xl font-bold'>Intercepting Login</h1>
</div>
);
};
export default InterceptingLogin;
Login page への Link を root layout に追加する。
export default function RootLayout({
children,
auth,
}: Readonly<{
children: React.ReactNode;
auth: React.ReactNode;
}>) {
return (
<html lang='en'>
<body className={inter.className}>
+ <Header />
{children}
<div className='mt-5 border p-5'>
<div>Auth Slot comes here↓↓↓↓</div>
<div>{auth}</div>
</div>
</body>
</html>
);
}
Header component
shadcn の NavigationMenu を利用。
'use client';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
import Link from 'next/link';
export const Header = () => {
return (
<header className='p-2'>
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<Link href='/' legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Home
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href='/login' legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Login
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</header>
);
};
Home => Login 移動
この状態で、Header の navitation link から /login
へ画面遷移すると app/(.)login/page.tsx
が表示される↓
Login リロード
/login
で browser をリロードすると app/login/page.tsx
が表示される↓

@auth slot に intercepting route 作成
ここから公式 Doc 通りに、@auth slot に login page の Intercepting Route を作成する。
app/@auth/(.)login/page.tsx
を作成。
const AuthInterceptingLogin = () => {
return (
<div className='text-red-600 text-lg font-bold'>
Intercepting Login @auth slot
</div>
);
};
export default AuthInterceptingLogin;
/
表示
1. Home
/
=> Login /login
に移動
2. Home
/login
リロード
3. Login 一旦整理
- Soft Navigation で
/login
へ移動すると、@auth slot の login page intercepting route (app/@auth/(.)login/page.tsx
) が表示される。 - 上記以外は、@auth slot の default.tsx (
app/@auth/default.tsx
) が表示される。

Parallel Routes と Intercepting Routes を組み合わせて作る Login Modal
ここまでで、Parallel Routes と Intercepting Routes についてだいぶ理解できた。
これらを組み合わせれば、これ↓ができるわけだな。
- Home page から Header の navigation で Login のリンクをクリックしたときは、Login 用の Modal を表示する
- 直接 Login page を開いた時は Login 専用の Page を表示する

やることを整理
- Home page から Header の navigation で Login のリンクをクリックしたときは、Login 用の Modal を表示する
- 直接 Login page を開いた時は Login 専用の Page を表示する
この体験を実現するために必要な実装項目を洗い出す。
直接 Login page を開いた時は Login 専用の Page を表示する
- Login Page を用意する
Home page から Header の navigation で Login のリンクをクリックしたときは、Login 用の Modal を表示する
- @auth slot はデフォで何も表示しない
- @auth slot に Login Page の intercepting route を用意して、Modal で Login Form を表示

いらないもの消しとく
Root Layout お掃除
export default function RootLayout({
children,
auth,
}: Readonly<{
children: React.ReactNode;
auth: React.ReactNode;
}>) {
return (
<html lang='en'>
<body className={inter.className}>
<Header />
{children}
{auth}
</body>
</html>
);
}

直接 Login page を開いた時は Login 専用の Page を表示する
チャチャっと Login Page の UI を作る。(@auth slot は後で消す。)
Login Page components
'use client';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useForm } from 'react-hook-form';
export const LoginForm = () => {
const form = useForm();
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(console.log)} className='grid gap-y-3'>
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder='shadcn' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder='********' {...field} type='password' />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit' className='mt-6'>
Submit
</Button>
</form>
</Form>
);
};
import { LoginForm } from './_form/login-form';
const LoginPage = () => {
return (
<div className='container relative h-[700px] grid place-items-center'>
<div className='mx-auto flex w-[400px] flex-col justify-center space-y-6'>
<div className='grid gap-y-2 text-center justify-items-center'>
<h1 className='text-2xl font-semibold tracking-tight'>
Welcome Back!
</h1>
<p className='text-sm text-muted-foreground'>
Enter your email and password below to login.
</p>
</div>
<LoginForm />
</div>
</div>
);
};
export default LoginPage;

Home page から Header の navigation で Login のリンクをクリックしたときは、Login 用の Modal を表示する
@auth slot はデフォで何も表示しない
app/@auth/default.tsx
を修正して、何も表示しないようにする。
const AuthSlotDefault = () => {
return null;
};
export default AuthSlotDefault;
Login page から "@auth slot default" が消えたこと確認。
@auth slot に Login Page の intercepting route を用意して、Modal で Login Form を表示
shadcn UI の Dialog をデフォで開く状態にして LoginForm component を表示。
'use client';
import { LoginForm } from '@/app/login/_form/login-form';
import { Dialog, DialogContent } from '@/components/ui/dialog';
const AuthInterceptingLogin = () => {
return (
<Dialog open>
<DialogContent>
<h2 className='text-2xl font-semibold tracking-tight'>Login</h2>
<LoginForm />
</DialogContent>
</Dialog>
);
};
export default AuthInterceptingLogin;
HOME (/
) => Login Page (/login
) に移動すると Modal が表示される↓

ブラウザの back/forward navigation による Modal の開閉確認
@auth slot の intercepting route により、ブラウザの back/forward navigation による Modal の開閉ができるようになった↓

クリックして Modal を閉じれるようにする
現状、Modal の閉じるボタンや overlay をクリックしても閉じれないので、ちゃんと閉じれるように対応する。
Modal を閉じる際に、router.back()
するようにしてあげる↓
const AuthInterceptingLogin = () => {
const router = useRouter();
return (
<Dialog
open
+ onOpenChange={(isOpen) => {
+ if (!isOpen) {
+ router.back();
+ }
+ }}
>
<DialogContent>
<h2 className='text-2xl font-semibold tracking-tight'>Login</h2>
<LoginForm />
</DialogContent>
</Dialog>
);
};

別のページへ遷移したときに Modal 閉じない問題
router.back()
では Modal 閉じれるが、Soft Navigation (router.push('/')
のような形) でHome 画面へ遷移しようとすると、Modal が開きっぱなしになる。
試しに、Login Modal の中に Home への Link を置いて検証。
//...
<Button asChild variant={'link'}>
<Link href='/'>Navigate to Home</Link>
</Button>
//...
"Navigate to Home" を押しても、Modal は開きっぱし↓
原因
Soft Navigation: During client-side navigation, Next.js will perform a partial render, changing the subpage within the slot, while maintaining the other slot's active subpages, even if they don't match the current URL.
parallel route では、すでにある slot の page が表示されている状態で Soft Navigation が生じ、その slot に該当する route が存在しない場合は、現状表示されている page がそのまま生き残る挙動になるらしい。
対応
When using the Link component to navigate away from a page that shouldn't render the @auth slot anymore, we use a catch-all route that returns null.
@auth slot 内に catch-all route を作って対応する。
login page の intercepting route 以外では、@auth slot では何も表示しないよう null を返す catch-all route 作成。
公式 Doc の example では [...catchAll]
としているが、これだと /
の時が該当しないので optional catch all segment [[...catchAll]
を使う。
export default function CatchAll() {
return null;
}
これで Login Modal から別の route へ Soft Navigation した際も Modal が正しく閉じられるようになる。

最終動作確認
- HOME => Login 遷移で Modal 表示
- back navigation で Modal 閉じる
- foward navigation で Modal 表示
- Modal の "閉じるボタン" クリックで Modal 閉じる
- Modal の overlay クリックで Modal 閉じる
- Modal 内 Link から Home へ遷移して Modal 閉じる
- Modal 開いた状態でリロードして Login Page を表示

Rarallel Routes と Intercepting Routes の Modal まとめ
Soft Navigation 時は Modal を開くが、Had Navigation 時は個別のページを表示したい
これを実現するためには、Parallel Route で Modal を表示するための slot を用意し、以下を実装すればOK
- Modal を表示したい route の Intercepting Route を作成
- "閉じる" フローで
route.back()
を実装
- "閉じる" フローで
- slot の default.js を用意 (何も表示しない)
- slot の optional catch-all route を用意 (何も表示しない)