📝

Next.jsのIntercepting RoutesとParallel Routesを使ってモダンなUXを実現する

2025/02/01に公開

今回はNext.js 13.3のApp Routerから導入されたIntercepting RoutesParallel Routesを使用して、モダンなUXを実現する方法を解説します。

Intercepting Routesの動作

実際に動作するデモ

https://nextjs-intercepting-routes-example-igz0.vercel.app/articles
※ モバイルでは通常の記事一覧表示になりますが、PCではサイドバーを使ったプレビュー表示になります。

ソースコード

https://github.com/igz0/nextjs-intercepting-routes-example

1. はじめに

このプロジェクトでは以下のような機能を実装します。

  • 記事一覧ページ
    • PC表示時は2カラムレイアウト
      • 記事一覧の右側にサイドバーで記事プレビューを表示
    • モバイル表示時は1カラムレイアウト
      • 通常の記事一覧ページを表示
  • 記事詳細ページ
    • PC・モバイルで共通のページ
    • 通常のブログの記事ページのように記事の内容を表示

(1) Intercepting RoutesとURLの動作

Intercepting Routesを利用することで、以下のような体験をユーザーに提供できます。

  1. サイドバー表示時のURL

    • PCでサイドバーを開いた場合は、URLは/articles/[id]に変更されます
    • これにより、サイドバー表示中のURLの共有や、ブックマークが可能になります
    • ブラウザの戻るボタンでサイドバーを閉じることができます
  2. 直接アクセス時の挙動

    • /articles/[id]に直接アクセスした場合:
      • モバイルの場合:通常の記事詳細ページが表示されます
      • PCの場合:記事一覧ページ上にサイドバーで表示されます
    • これにより、共有されたURLは両方のデバイスで適切に表示されます
  3. ナビゲーション履歴

// 記事一覧 → サイドバー表示 → サイドバーを閉じる の場合
/articles → /articles/[id]/articles
  • サイドバーを開くと履歴スタックに新しいエントリーが追加されます
  • ブラウザの戻るボタンでサイドバーを閉じると前のURLに戻ります
  1. SEO対策

    • 同じコンテンツに対して一貫したURLが維持されます
    • 検索エンジンは通常の記事詳細ページとしてコンテンツをクロールできます

(2) プロジェクトの構造

src/app/
├── _constants/
│   └── articles.ts          # 記事データ
└── articles/
    ├── (details)/           # 記事詳細ページ
    │   └── [id]/
    │       └── page.tsx       # 記事詳細ページのページ
    └── (list)/             # 記事一覧ページ
        ├── @sidebar/         # サイドバー用のParallel Route
        │   ├── (...)articles/[id]/ # インターセプトするルート
        │   │   └── page.tsx          # サイドバー表示用のページ
        │   └── default.tsx           # サイドバーが表示されていない時のデフォルトページ
        ├── layout.tsx             # 記事一覧ページのレイアウト
        └── page.tsx                # 記事一覧ページのページ

2. 実装の解説

(1) Parallel Routesの設定

まず、記事一覧ページのレイアウトで@sidebarスロットを定義します。

src/app/articles/(list)/layout.tsx
import { ReactNode } from 'react';

export default function Layout({
  children,
  sidebar,
}: {
  children: ReactNode;
  sidebar: ReactNode;
}) {
  return (
      <div className="max-w-4xl mx-auto p-6">
        <h1 className="text-3xl font-bold mb-8">Blog Articles</h1>
        <div className="flex gap-4">
          <div className="flex-1 md:w-1/2">{children}</div>
          <div className="hidden md:block md:w-1/2">{ sidebar }</div>
        </div>
      </div>
  );
}

このレイアウトでは、childrensidebarの2つのスロットを受け取ります。sidebarスロットはPC表示時のみ表示され、記事のプレビューを表示します。

(2) Intercepting Routesの設定

記事一覧から詳細ページへの遷移をインターセプト(妨害)して、サイドバー表示を実現するために、@sidebarディレクトリ内に(...)articles/[id]というパスを作成します。

src/app/articles/(list)/@sidebar/(...)articles/[id]/page.tsx
import { articles } from '@/app/_constants/articles';
import { notFound } from 'next/navigation';

export default async function Articlesidebar(
  props: {
    params: Promise<{ id: string }>;
  }
) {
  const params = await props.params;
  const article = articles.find((article) => article.id === params.id);

  if (!article) {
    notFound();
  }

  return (
    <div className="bg-white p-6 rounded-lg shadow-lg overflow-y-auto max-h-[90vh]">
      <article>
        <h1 className="text-2xl font-bold mb-4">{article.title}</h1>
        <time className="text-gray-500 mb-6 block">{article.date}</time>
        <div className="prose">
          {article.content.split('\n\n').map((paragraph, index) => (
            <p key={index} className="mb-4">
              {paragraph}
            </p>
          ))}
        </div>
      </article>
    </div>
  );
}

(...)を使用することで、/articles/[id]へのナビゲーションをインターセプトし、PC表示時はサイドバーで表示します。

(3) レスポンシブな記事一覧の実装

記事一覧ページでは、デバイスに応じて異なるナビゲーション方法を実装します。

src/app/articles/(list)/page.tsx
import Link from 'next/link';
import { articles } from '@/app/_constants/articles';

export default function ArticlesPage() {
  return (
    <div className="space-y-6">
      {articles.map((article) => (
        <main key={article.id} className="border rounded-lg p-6 hover:shadow-lg transition-shadow">
          <div className='hidden md:block'>
            <Link href={`/articles/${article.id}`}>
              <h2 className="text-2xl font-semibold mb-2 hover:text-blue-600">{article.title}</h2>
              <p className="text-gray-600 mb-2">{article.excerpt}</p>
              <time className="text-sm text-gray-500">{article.date}</time>
            </Link>
          </div>
          <div className='block md:hidden'>
            <a href={`/articles/${article.id}`}>
              <h2 className="text-2xl font-semibold mb-2 hover:text-blue-600">{article.title}</h2>
              <p className="text-gray-600 mb-2">{article.excerpt}</p>
              <time className="text-sm text-gray-500">{article.date}</time>
            </a>
          </div>
        </main>
      ))}
    </div>
  );
}

PC表示時はLinkコンポーネントを使用してソフトナビゲーションを有効にし、サイドバー表示を実現します。一方、モバイル表示時は通常のaタグを用いてハードナビゲーションを利用することで完全なページ遷移を行います。

(4) 記事詳細ページの実装

モバイル表示時や直接URLにアクセスした場合の記事詳細ページを実装します。

src/app/articles/(details)/[id]/page.tsx
import { articles } from '@/app/_constants/articles';
import Link from 'next/link';
import { notFound } from 'next/navigation';

export default async function ArticlePage(
  props: {
    params: Promise<{ id: string }>;
  }
) {
  const params = await props.params;
  const article = articles.find((article) => article.id === params.id);

  if (!article) {
    notFound();
  }

  return (
    <div className="max-w-4xl mx-auto p-6">
      <Link href="/articles" className="text-blue-600 hover:underline mb-8 inline-block">
        ← Back to Articles
      </Link>
      <article className="mt-6">
        <h1 className="text-4xl font-bold mb-4">{article.title}</h1>
        <time className="text-gray-500 mb-8 block">{article.date}</time>
        <div className="prose lg:prose-xl">
          {article.content.split('\n\n').map((paragraph, index) => (
            <p key={index} className="mb-4">
              {paragraph}
            </p>
          ))}
        </div>
      </article>
    </div>
  );
}

3. ディレクトリ構造の詳細解説

(1) 使用している特殊な記号の意味

  1. @sidebar - Parallel Routesのスロット

    • @で始まるフォルダはParallel Routesのスロットとして扱われます
    • このスロットは親レイアウトのpropsとして渡されます
    • URLには影響しません(@sidebar/@sidebarとしてアクセスできません)
  2. (list)(details) - ルートグループ

    • ()で囲まれたフォルダはルートグループとして扱われます
    • URLの構造には影響しません
    • 関連するルートを論理的にグループ化するために使用
  3. (...) - Intercepting Routes

    • (...)はIntercepting Routesにおいて、ルート(app)ディレクトリからのセグメントをインターセプトするために使用します
    • この例では(...)articles/[id]/articles/[id]へのナビゲーションをインターセプト(妨害)してルーティングを乗っ取ります
    • PCでサイドバー表示、モバイルで通常遷移という分岐を実現するために使用

(2) Parallel Routesの詳細

src/app/articles/(list)/layout.tsx
import { ReactNode } from 'react';

export default function Layout({
  children,
  sidebar,
}: {
  children: ReactNode;
  sidebar: ReactNode;
}) {
  return (
      <div className="max-w-4xl mx-auto p-6">
        <h1 className="text-3xl font-bold mb-8">Blog Articles</h1>
        <div className="flex gap-4">
          <div className="flex-1 md:w-1/2">{children}</div>
          <div className="hidden md:block md:w-1/2">{ sidebar }</div>
        </div>
      </div>
  );
}
  • sidebarプロパティは@sidebarディレクトリの内容を受け取ります
  • childrenは暗黙的なスロットで、デフォルトのコンテンツを表示します
  • 各スロットは独立してレンダリングされ、独自のローディングやエラー状態を持つことができます

(3) Intercepting Routesの動作

src/app/articles/
├── (details)/[id]/     # 通常の記事詳細ページ
└── (list)/
    ├── @sidebar/         # サイドバー用のParallel Route
    │   └── (...)articles/[id]/ # インターセプトするルート

インターセプトの利用方法は以下の通りです。

  1. (.) - 同じレベルのセグメントをインターセプト
  2. (..) - 1レベル上のセグメントをインターセプト
  3. (..)(..) - 2レベル上のセグメントをインターセプト
  4. (...) - ルート(app)ディレクトリからのセグメントをインターセプト

詳細

Routing: Intercepting Routes | Next.js

(4) デフォルトの挙動

src/app/articles/(list)/@sidebar/default.tsx
export default function Default() {
  return (
    <div>
      <h2>Select an article</h2>
    </div>
  );
} 
  • default.tsxはスロットのデフォルト状態を定義します
  • サイドバーが表示されていない時は「Select an article」と表示されます
  • ページの初回ロードやリフレッシュ時に使用されます

(5) ナビゲーションの種類

  1. ソフトナビゲーション
<Link href={`/articles/${article.id}`}>
  • PC表示用
  • Next.jsのLinkコンポーネントを使用
  • サイドバー表示が有効
  • 各スロットの状態が保持される
  1. ハードナビゲーション
<a href={`/articles/${article.id}`}>
  • モバイル表示用
  • 通常のaタグを使用
  • フルページ遷移

4. まとめ

このようにNext.js App RouterのIntercepting RoutesとParallel Routesを組み合わせることで、以下のような高度なUI実装が可能になります。

  1. デバイスに応じた最適なユーザー体験の提供
  2. PCではサイドバーを使用した効率的なコンテンツ閲覧
  3. モバイルでは従来通りの完全なページ遷移
  4. 同じコンテンツに対する複数の表示方法の共存

これらの機能を使用することで、モダンでインタラクティブなWebアプリケーションを、複雑なクライアントサイドの状態管理なしに実装することができました。


読んで頂き、ありがとうございました!!

Discussion