🌙

【Next.js ✗ App Router】Routingまとめ 〜後半〜

2024/02/01に公開

はじめに

Next.jsのApp RouterのRoutingのまとめの後半になります。
前半はNext.js触ったことがある人なら大体分かる内容で、基礎的な部分が多かったと思います。

今回は、前回と比べると若干、難解な部分があるとは思いますが、これを理解すれば、実装の幅はかなり高まると思うので、やっていきましょう。

前半の記事も読みたい方は以下から読むことができます。
https://zenn.dev/sc30gsw/articles/64240c11d7dbf9

Routing

Route Groups

これは、appディレクトリ内のディレクトリに対して使用できる機能です。
通常、appディレクトリではネストされたフォルダーはURLパスにマッピングされます。

ただ、Route Groupsを使えば、該当のフォルダーがURLパスにマッピングされないようにすることができます。

これにより、同じルートレベルで共通のレイアウトを使用することができたりします。
実装は以下のようにグループ化したいディレクトリを()で囲むことで実装できます。

app/
|-- page.tsx
|-- (dashboard)
|   |-- layout.tsx
|   |-- admin/
|       |-- page.tsx
|   |-- users/
|       |-- page.tsx

この実装では、/admin/usersでアクセスします。
/dashboardは含まれません)

このようにすることで、ネストされたadminとusersに対して、dashboardのlayout.tsxを共通レイアウトに指定することができます。

もちろん、複数のグループを作成することも可能で、例えば、同じ階層レベルで別のグループを作れば、そのグループのみに適用するレイアウトを作ることができます。

このようにURLに影響は与えることなく、レイアウトの作成ができるため、わざわざURLを増やしてまで実装すべきでない場合や様々な要件に対応することが可能です。

https://nextjs.org/docs/app/building-your-application/routing/route-groups

Parallel Routes

これは並列ルートと呼ばれるもので、同じレイアウト内で1つ以上のページを同時にレンダリングしたり、条件付きでレンダリングすることができます。

並列ルートは、以下のようにスロットを使用して@ディレクトリ名とします。
例えば、以下の場合は、/dashboardのルートに並列で@analytics@teamが定義されている状態です。
(こちらもRoute Groupsと同様にスロットのディレクトリはURLに含まれることはありません)

app/
|-- page.tsx
|-- dashboard/
|   |-- layout.tsx
|   |-- page.tsx
|   |-- @anlytics/
|       |-- page.tsx
|   |-- @team
|       |-- page.tsx

並列ルートを使用するとなにができるのか、ここまでだといまいちピンときていないと思います。
実際の、実装を見ると、すごさがわかります。

app/dashboard/layout.tsx
import React, { ReactNode } from 'react'

const DashboardLayout = ({
  children,
  team,
  analytics,
}: Readonly<{
  children: ReactNode
  team: ReactNode
  analytics: ReactNode
}>) => {
  return (
    <div>
      <div>DashboardLayout</div>
      <div>{team}</div>
      <div>{analytics}</div>
      <div>{children}</div>
    </div>
  )
}

export default DashboardLayout

各スロットの実装は以下のとおりです。

app/dashboard/@analytics/page.tsx
import React from 'react'

const DashboardAnalytics = () => {
  return <div>DashboardAnalytics</div>
}

export default DashboardAnalytics
app/dashboard/@team/page.tsx
import React from 'react'

const DashboardTeam = () => {
  return <div>DashboardTeam</div>
}

export default DashboardTeam

上記のように、共通レイアウトの部分で複数のページをレンダリングすることができます。
実際の画面上では以下のようになります。
Parallel Route

このように複数のページを1つのレイアウトで共通化することができます。
例えば、X(旧Twitter)のタイムラインの投稿一覧UIとサイドのトレンドの部分などを並列ルートでレンダリングするという使い方などができると思います。

default.tsx(js・jsx)

この並列レンダリングではdefault.tsxを定義することで、スロット内や該当のルートにスロットのpage.tsxがない場合にレンダリングするファイルを定義することができます。

公式では、以下のような定義となっています。

default.js初期ロードまたはページ全体のリロード中に、一致しないスロットのフォールバックとしてレンダリングするファイルを定義できます。

例えば、以下のルーティングがあるとしましょう。

app/
|-- page.tsx
|-- dashboard/
|   |-- layout.tsx
|   |-- page.tsx
|   |-- @anlytics/
|       |-- page.tsx
|       |-- default.tsx
|   |-- @team
|       |-- settings/
|       |   |-- page.tsx
|       |-- default.tsx
|       |-- page.tsx
|   |-- setings/
|       |-- page.tsx

そして、dashboardのlayoutは上記と同様に、以下とします。

app/dashboard/layout.tsx
import React, { ReactNode } from 'react'

const DashboardLayout = ({
  children,
  team,
  analytics,
}: Readonly<{
  children: ReactNode
  team: ReactNode
  analytics: ReactNode
}>) => {
  return (
    <div>
      <div>DashboardLayout</div>
      <div>{team}</div>
      <div>{analytics}</div>
      <div>{children}</div>
    </div>
  )
}

export default DashboardLayout

この場合、/dashboardにアクセスした際は、@analytics@teamも各スロット内のpage.tsxをレンダリングします。

次に/dashboard/settingsにアクセスする場合を見ていきましょう。
この場合は、@teamには@team/settings/page.tsxがあるので、それをレンダリングします。
ところが、@analyticsには@analytics/settings/page.tsxどころか、settingsディレクトリすらありません。
しかし、dashboard/layout.tsxではしっかりと@analyticsスロットのページがレンダリングされています。

このような場合に、default.tsxがレンダリングされます。
また、このような場合にdefault.tsxがないと、Next.jsは404ページを表示します。
まとめると、スロット内でも該当のルートに対応するページが存在しないといけないという前提があるため、ルートでdefault.tsxを実装する必要があるということです。

ちなみに、上記ルーティングの実際の画面上の表示は以下のようになります。

  • DashboardLayout: layoutの一番上のdiv
  • Team Settings: @team/settings/page.tsx
  • analytics: @analytics/default.tsx
  • DashboardLayoutのchildren: dashboard/settings/page.tsx

default_tsx

条件付きレンダリング

並列ルートでは、以下のように条件付きでレンダリングするレイアウトを変更することができます。

import { checkUserRole } from '@/lib/auth'
 
export default function Layout({
  user,
  admin,
}: {
  user: React.ReactNode
  admin: React.ReactNode
}) {
  const role = checkUserRole()
  return <>{role === 'admin' ? admin : user}</>
}

さらに、並列ルートの応用として、次に紹介するInterceptiong Routesとの組み合わせでモーダルを作成するなどができます。(これは公式ドキュメントでも記載があるので、見てみてください)

https://nextjs.org/docs/app/building-your-application/routing/parallel-routes

Intercepting Routes

これは言葉のままなのですが、ルートを補足することができる機能です。
何ができるのかというと、現在のページから別のルートを読み込むことができます。

これにより、ユーザーはわざわざページ遷移せずとも別のルートに存在するコンテンツを見ることができます。
例えば、SNS投稿一覧から詳細を表示する場合に、詳細をモーダル表示したいという要件があるとします。その場合、この機能を使用すれば、一覧ページから該当の投稿詳細をページ遷移することなく、モーダルなどで表示することができます。

以下は、簡易的な実装例になるのですが、モーダルを表示した際に、URLが/todosから/todo/view/[id]になっているのがわかると思います。
これがInterception Routesです。
Interception Routes

また、モーダルを表示したら、そのまま詳細いきたいということもあると思います。
その場合、ページを更新すると詳細ページに遷移するという仕様になっているようです。
もしくは、共有可能なURLをクリックして遷移させるという方法もあります。

では、具体的に、どのように実装したら良いのかという部分に行きたいと思います。
Intercepting Routesは以下のようなルーティングをすることで実装できます。

まず、補足したいルートを決めるのですが、今回は一覧から詳細を補足したいので、todosのルート内で補足することになります。
そのため、todosルート内に(..)todo/としてtodoルートを補足するようルーティングします。

この(..)などはimport文のfrom句の部分と同じ内容を示します。
公式での区分は以下のようになっています。

  • (.)同じレベルのセグメントを一致させる
  • (..)1 つ上のレベルのセグメントと一致する
  • (..)(..)2 レベル上のセグメントと一致する
  • (...)ルート appディレクトリのセグメントと一致するようにする
app/
|-- page.tsx
|-- todo/
|   |-- view/
|       |-- [id]
|           |-- page.tsx
|-- todos/
|   |-- layout.tsx
|   |-- page.tsx
|   |-- (..)todo/
|       |-- view/
|           |-- [id]
|               |-- page.tsx

次に、実際の実装を見ていきましょう。

まず、詳細ページですが、以下のようになっています。

components/TodoView.tsx
import React from 'react'

export const TodoView = ({ id }: Readonly<{ id: string }>) => {
  return (
    <div>
      <div>TodoView</div>
      <div>{id}</div>
    </div>
  )
}
app/todo/view/[id]/page.tsx
import { TodoView } from '@/components/todo/TodoView'

const TodoPage = ({ params }: Readonly<{ params: { id: string } }>) => {
  return <TodoView id={params.id} />
}

export default TodoPage

これを補足すると以下のようになります。

app/todos/(..todo)/view/[id]/page.tsx
import React from 'react'

import { TodoView } from '@/components/todo/TodoView'
import { BackModal } from '@/components/ui/BackModal'

const TodosViewPage = ({ params: { id } }: { params: { id: string } }) => {
  return (
    <BackModal>
      <TodoView id={id} />
    </BackModal>
  )
}

export default TodosViewPage

このようにすることで、/todosのURLでtodo詳細を補足し、表示することができます。
ただ、このままだと、ルーティングを補足しただけで、実際に詳細画面をモーダル表示できません。

以下を見るとわかると思います。URLに注目してください。
まずは、一覧画面です。
todos

ここからクリックで詳細をモーダル表示します。
URLが切り替わっていて、一覧が表示されています。
どうやらルートの補足はできているようですが、詳細が表示できていないということがわかります。
todos-view

この問題を解決するためにParallel Routesを使用します。

Parallel Routesと組み合わせる

まず、ルーティングを編集します。
@modalをルーティングに追加するよう修正します。

app/
|-- page.tsx
|-- todo/
|   |-- view/
|       |-- [id]
|           |-- page.tsx
|-- todos/
|   |-- layout.tsx
|   |-- page.tsx
|   |-- @modal/
|       |-- default.tsx
|       |-- (..)todo/
|           |-- view/
|               |-- [id]
|                   |-- page.tsx

そして、layoutを実装します。

app/todos/layout.tsx
import React, { ReactNode } from 'react'

const Layout = ({
  children,
  modal,
}: Readonly<{ children: ReactNode; modal: ReactNode }>) => (
  <div className={'space-y-5'}>
    <div className={'text-3xl'}>ToDo リスト</div>
    <div>{children}</div>
    {modal}
  </div>
)

export default Layout

まずchildrenですが、ここにはtodos/page.tsxが入ります。

todos/page.tsx
import { ToDoList } from '@/components/todo/TodoList'
import React from 'react'

const TodoListPage = () => {
  return (
    <div className="bg-slate-400">
      <ToDoList />
    </div>
  )
}

export default TodoListPage

TodoListは以下のようになっています。
useRouterで遷移したい際に該当の詳細ページを補足するという処理の流れになります。

components/TodoList.tsx
'use client'

import { useRouter } from 'next/navigation'

export const todoData = [
  { id: '1', title: '片付け' },
  { id: '2', title: '買い物' },
  { id: '3', title: '振込' },
]

export const ToDoList = () => {
  const { push } = useRouter()

  return (
    <div>
      {todoData.map((todo) => (
        <ul
          key={todo.id}
          className={'flex space-x-2 border-b-2 p-2 items-center'}
        >
          {/* biome-ignore lint/a11y/useKeyWithClickEvents: necessary processing */}
          <li
            onClick={() => push(`/todo/view/${todo.id}`)}
            className={'cursor-pointer flex-1'}
          >
            {todo.title}
          </li>
          <button
            type="button"
            onClick={() => push(`/todo/edit/${todo.id}`)}
            className={'w-32 bg-blue-300 rounded-md p-1'}
          >
            編集
          </button>
        </ul>
      ))}
    </div>
  )
}

そしてlayout.tsxmodalには@modal/(..todo)/view/[id]/page.tsxが入ります。
このパス(/todo/view/[id])以外のパスの場合は、default.tsxmodalに入ります。
まとめると、補足したパス以外のパスの場合にはdefault.tsxがレンダリングされるということです。

今回の場合は、404を表示したいので、default.tsxはnullを返すようにしています。

さらに、先程、詳細ページに遷移するにはブラウザのリロードが必要といいました。
今回はモーダル表示のみにとどめたいので、詳細ページに遷移したい際に/todosにリダイレクトさせるようにしています。

app/todo/view/[id]/page.tsx
import { redirect } from 'next/navigation'

const TodoDetailPage = () => {
  return redirect('/todos')
}

export default TodoDetailPage

このようにRoutingを駆使すれば、様々な実装ができます。
ちなみに、この実装は以下の記事を大いに参考にしているので、こちらも読んでみてください。
https://zenn.dev/ficilcom/articles/app_router_intercept_route

https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes

Middleware

ミドルウェアですが、これはリクエストが完了する前に実行する処理を記述することができます。
認証・認可の処理はここで実装します。

これはプロジェクトのルートディレクトリにmiddleware.tsを作成し、その中に処理を記述することで実装できます。

実装例ですが、公式のサンプルでは、/about以下のURLに遷移してきたら、/homeにリダイレクトさせるような処理になっています。

middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  return NextResponse.redirect(new URL('/home', request.url))
}
 
export const config = {
  matcher: '/about/:path*',
}

middlewareの実行順

middlewareには実行順が有り、リクエストが行われた際にどの順序で処理が行われるかが決まっています。具体的には、以下の順序で実行されていきます。

1. next.config.jsからのヘッダー:カスタムHTTPヘッダーが設定されている場合、これが最初に適用されます。
2. next.config.jsからのリダイレクト:カスタムリダイレクトが設定されている場合、これが次に適用されます。
3. ミドルウェア:ミドルウェアが存在する場合、これが次に実行されます。ミドルウェアは、リクエストとレスポンスを操作するためのカスタムロジックを提供します。これには、リダイレクトや書き換えなどが含まれます。
4. next.config.jsからのbeforeFiles書き換え:beforeFilesステージでのカスタム書き換えが設定されている場合、これが次に適用されます。
5. ファイルシステムのルート:public/、_next/static/、pages/、app/などのファイルシステムベースのルーティングが次に適用されます。
6. afterFiles(next.config.jsからの書き換え):afterFilesステージでのカスタム書き換えが設定されている場合、これが次に適用されます。
7. 動的ルート:/blog/[slug]などの動的ルートが次に適用されます。
8. fallback(next.config.jsからの書き換え):fallbackステージでのカスタム書き換えが設定されている場合、これが最後に適用されます。

matcher

matcherで特定のパスや複数のパスに一致した場合にmiddleware関数を実行するようにできます。
以下のように実装できます。

middleware.ts
export const config = {
  matcher: '/about/:path*',
}
middleware.ts
export const config = {
  matcher: ['/about/:path*', '/dashboard/:path*'],
}

また、正規表現も使用でき、特定のパスを除外して、それ以外のパスにマッチした際という条件を指定することもできます。
以下の例も公式サンプルですが、apinext/staticnext/imagefavicon.icoで始まるパスを除外しています。

これに配列の2つ目・3つ目の要素に/auth/:path*/login/:path*などを追記すれば、認証周りのUIにのみ遷移させることもできます。

middleware.ts
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

middlewareは他にもCookieを使用できたり、headerの設定ができたりするので、そこは公式ドキュメントを読んでみてください。
(本記事ではそこら辺までは解説しません。)

https://nextjs.org/docs/app/building-your-application/routing/middleware

Redirecting

Next.jsではリダイレクト処理を行う方法がいくつかあります。
以下に示した方法でリダイレクト処理を行うことができます。

API 目的 使用場所 ステータスコード
redirect 突然変異またはイベントの後にユーザーをリダイレクトする サーバーコンポーネント、サーバーアクション、ルートハンドラー 307 (一時) または 303 (サーバーアクション)
permanentRedirect 突然変異またはイベントの後にユーザーをリダイレクトする サーバーコンポーネント、サーバーアクション、ルートハンドラー 308(常設)
useRouter クライアント側のナビゲーションを実行する クライアントコンポーネントのイベントハンドラー 該当なし
redirects in next.config.js パスに基づいて受信リクエストをリダイレクトする next.config.js 307 (臨時) または 308 (常設)
NextResponse.redirect 条件に基づいて受信リクエストをリダイレクトする ミドルウェア どれでも

それでは、各APIについて見ていきましょう。

redirect

redirectはサーバーコンポーネント・APIルートハンドラー・サーバーアクションで呼び出すことができます。
以下のように実装します。

action.ts
'use server'
 
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
 
export async function createPost(id: string) {
  try {
    // Call database
  } catch (error) {
    // Handle errors
  }
 
  revalidatePath('/posts')
  redirect(`/post/${id}`)
}

revalidatePath/postsに関連するキャッシュを削除し、詳細画面にリダイレクトするようになっています。
redirectは内部的にエラーがスローされるのでtry-catchの外で呼び出す必要があるので、そこは注意してください。また、前半の記事でも触れましたが、絶対URLを指定することもできるので、外部リンクにリダイレクトすることもできます。

permanentRedirect

permanentRedierctを使用するとユーザーを別のURLに永続的にリダイレクトさせることができます。
要するに、ブラウザバックしても元のURLに戻ることはできないとうことです。
この関数も、サーバーコンポーネント・APIルートハンドラー・サーバーアクションで呼び出すことができます。

以下は公式の実装例です。

'use server'
 
import { permanentRedirect } from 'next/navigation'
import { revalidateTag } from 'next/cache'
 
export async function updateUsername(username: string, formData: FormData) {
  try {
    // Call database
  } catch (error) {
    // Handle errors
  }
 
  revalidateTag('username')
  permanentRedirect(`/profile/${username}`)
}

revalidateTagでユーザー名を更新したあとに、usernameタグが指定されているデータフェッチのキャッシュを削除し、キャッシュを更新して、該当のURLにリダイレクトさせます。
この例では更新前のプロフィールの画面に戻れないように永続的なリダイレクト処理が実装されています。

このpermanenteRedirectも絶対URLを受け入れるので外部リンクを指定することができます。

useRouter push

useRouterpushはクライアントコンポーネントのイベントハンドラ内で使用します。
以下のようにします。

'use client'
 
import { useRouter } from 'next/navigation'
 
export default function Page() {
  const router = useRouter()
 
  return (
    <button type="button" onClick={() => router.push('/dashboard')}>
      Dashboard
    </button>
  )
}

ただ、プログラム的にユーザーをリダイレクトさせる必要がない場合は、Next.jsはLinkコンポーネントの仕様を推奨しているので、そこは覚えておきましょう。

redirects in next.config.js

これはnext.configredirectsオプションを使用することで実現できます。

このリダイレクト処理は、リクエストのパスを別のパスにリダイレクトするため、ページのURL構造を変更する場合や、事前にわかっているリダイレクトのリストがある場合に便利です。
簡単に言うと、ウェブサイトでURLを変更(Next.jsならディレクトリ構造を変更)した場合だったり、特定の古いURLを新しいURLに紐づけたい場合に有効ということです。

以下のように、各プロパティを指定します。

  • source: リダイレクトのもととなるURL
  • destination: リダイレクト先のURL
  • permanent: リダイレクトが永続的かどうか(trueで永続化)
next.config.js
module.exports = {
  async redirects() {
    return [
      // Basic redirect
      {
        source: '/about',
        destination: '/',
        permanent: true,
      },
      // Wildcard path matching
      {
        source: '/blog/:slug',
        destination: '/news/:slug',
        permanent: true,
      },
    ]
  },
}

ちなみに、このリダイレクト処理はmiddlewareの前に実行されます。

NextResponse.redirect in middleware

このリダイレクトはリクエストが完了する前に実行されます。
このNextResponse.redirectは条件(認証・セッションの有無など)に基づいてユーザーをリダイレクトさせる場合だったり、多数のリダイレクト(SNSなど多数のユーザーが使用する場合)に非常に有効です。
(アプリケーションを作るとなると、ほぼ確実に実装することになります)

いかが実装例ですが、ユーザーが認証されていない場合、/loginにリダイレクトする処理になっています。

middleware.ts
import { NextResponse, NextRequest } from 'next/server'
import { authenticate } from 'auth-provider'
 
export function middleware(request: NextRequest) {
  const isAuthenticated = authenticate(request)
 
  // If the user is authenticated, continue as normal
  if (isAuthenticated) {
    return NextResponse.next()
  }
 
  // Redirect to login page if not authenticated
  return NextResponse.redirect(new URL('/login', request.url))
}
 
export const config = {
  matcher: '/dashboard/:path*',
}

各リダイレクトを状況に応じて使えるようにしておくと良いと思います。

https://nextjs.org/docs/app/building-your-application/routing/redirecting

おわりに

今回は、Routingの機能の一つにAPIを実装できるRoute Handlersについては触れていません。
理由としては、フロントエンドのRoutingのほうが実装の幅を広げるための知見としては、マストだと考えたためです。
ただ、APIもNext.jsらしい機能になっているので、興味のある方は読んでみてください。

https://nextjs.org/docs/app/building-your-application/routing/route-handlers

そして、最終的にはドキュメントを読み込むことが一番だと私自身は思っているので本記事の内容を踏まえた上でドキュメントを読んでみることをおすすめいたします。

少しでも本記事が参考になれば幸いです。

最後になりますが、前半の記事は以下から読むことができます。
https://zenn.dev/sc30gsw/articles/64240c11d7dbf9

参考文献

https://nextjs.org/docs/app/building-your-application/routing/route-groups
https://nextjs.org/docs/app/building-your-application/routing/parallel-routes
https://zenn.dev/ficilcom/articles/app_router_intercept_route
https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes
https://nextjs.org/docs/app/building-your-application/routing/middleware
https://nextjs.org/docs/app/building-your-application/routing/redirecting
https://nextjs.org/docs/app/building-your-application/routing/route-handlers
https://zenn.dev/sc30gsw/articles/64240c11d7dbf9

Discussion