🐘

個人サイトをNext.jsのPages RouterからApp Routerに移行したときの手順と学び

2023/07/17に公開

想定読者

  • Next.jsのPages Routerの使用経験がある方
  • App Routerをこれから試してみたい方

個人サイトをNext.jsのPages RouterからApp Routerに移行したのでその時の手順と移行を通じて得た学びについてまとめてみます。

3行まとめ

  • 公式の移行手順が明確で分かりやすい
  • ISR、SSR、SSGの切り替えが簡単に行える
  • ディレクトリ構造が整理されて管理しやすくなった

以下が実際に移行した際のPRです。

https://github.com/ryo-manba/portfolio-website/pull/54

移行手順

Next.js公式から提供されている移行手順に従って作業を進めていきます。具体的な手順は以下のとおりです。

  1. app ディレクトリの作成
  2. ルートレイアウトの作成
  3. next/headの置き換え
  4. ページの移行
  5. ルーティングフックの移行
  6. データ取得メソッドの移行
  7. スタイリング

1. app ディレクトリの作成

Next.jsのバージョンが13.4未満の場合は最新版にアップデートします。

pnpm install next@latest

次に、プロジェクトのルートか src/ の直下に app ディレクトリを作成します。

mkdir src/app # または mkdir app

コードを一箇所に集約するために、私は src/ ディレクトリ直下に app ディレクトリを作成しました。

2. ルートレイアウトの作成

次に、全てのルートに共通するレイアウトとなるapp/layout.tsxを作成します。App Routerを使用する際には、このファイルの作成が必須となります。

このルートレイアウトはPages Routerのpages/_app.tsxpages/_document.tsxの役割を引き継いでいます。そのため、既存のpages/_app.tsxpages/_document.tsxの内容をapp/layout.tsxに直接移行します。

しかし、Next.js 12のgetLayoutを使用している場合は、App RouterのNested Layoutsを活用し、以下の処理は省略することが可能です。

import type { AppPropsWithLayout } from 'next/app'

function MyApp({ Component, pageProps }: AppPropsWithLayout) {
  // 以下の処理は不要
  const getLayout = Component.getLayout ?? ((page) => page)
  return getLayout(<Component {...pageProps} />)
}

export default MyApp

Nested Layoutsとは、各ページの定義と同階層に、layout.tsxを定義することで、その階層以下には共通のレイアウトが適用されます。例えば、todo/layout.tsx というファイルを作成した場合、todo/以下のファイルには共通したレイアウトが適用されることになります。

この変更によりgetLayoutを用いて外部からレイアウトを取得する手間が省け、ディレクトリ構造が理解しやすくなると感じました。

3. next/headの置き換え

HTMLの<head>要素を管理するためのReactコンポーネントnext/headを新たな組み込みAPIであるMetadataに置き換えます。

Metadataを利用することで、以下のように更新することが可能となります。

// 元の next/head を使ったコード:
import Head from 'next/head'
 
export default function Page() {
  return (
    <>
      <Head>
        <title>My page title</title>
      </Head>
      <div>Hello</div>
    </>
  )
}
// Metadataを使ったコード
import { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: 'My Page Title',
}
 
export default function Page() {
  return <div>Hello</div>
}

この変更により、メタデータとコンポーネントが返す値が明確に分離され、構造がさらにわかりやすくなります。私個人としては、こちらの方がより好ましいと感じています。

さらに、app/layout.tsx 内でメタデータを定義することも可能で、その場合すべてのページにメタデータが適用されます。しかし、各ページで独自に Metadata を定義し、値を上書きすることも可能です。

4. ページの移行

app ディレクトリ内のページはデフォルトで Server Componentsとして動作します。これはクライアントコンポーネントである pages ディレクトリのページとは異なる挙動なので、その点に注意しながらページの置き換えを進めます。

Server Componentsについて詳しくは、次のページが参考になりました。
https://zenn.dev/uhyo/articles/react-server-components-multi-stage

以下の表は、置き換え後の対応関係を示しています。

pages ディレクトリ app ディレクトリ ルート
index.js page.js /
about.js about/page.js /about
blog/[slug].js blog/[slug]/page.js /blog/post-1

ルーティングシステムにはいくつかの変更点があります。これまでは pages/ 配下のファイル名がルートに対応していましたが、App Routerでは、ディレクトリ名がルートに対応し、その実体は page.tsx に定義します。

ページの移行は以下の手順で行いました。

  1. ページコンポーネントをクライアントコンポーネントに移動する。
  2. 移動したクライアントコンポーネントを app ディレクトリ内の新しい page.tsx ファイルにインポートする。

pages/index.tsx の移行手順を元に具体的な手順を見てみましょう。

まずは、pages/index.tsxapp/home-page.tsx に変更します。

そのままだと、app/home-page.tsxは Server Componentsとなり、useStateなどのクライアントコンポーネントのみで使用可能なフックを使うことができません。そこでファイルの先頭に "use client" を追加し、クライアントコンポーネントに変更します。これにより、home-page.tsxのコードはそのまま動作するようになります。

// app/home-page.tsx
"use client";

export default function HomePage = () => {
  // useStateが使用できる
  const [store, setStore] = useState(true);
  return (
    // 以下にpages/index.tsxのコードを貼る
  )
}

次に、app/page.tsx を作成し、home-page.tsx をインポートして表示します。

// app/page.tsx
import { HomePage } from './home-page';

const Home = () => {
  return <HomePage />
}

export default Home;

上記の場合、app/page.tsxは Server Componentsですが、実際に表示している HomePageコンポーネントはクライアントコンポーネントとなります。この手順を踏むことで、他のページも安全に移行することができました。

なお、ページが存在しない場合に表示される、pages/404.tsxは、app/not-found.tsxに置き換える必要がありました。これは単純にファイル名を変更するだけなので、簡単に移行することができました。

5. ルーティングフックの移行

App Routerでは、従来のuseRouterが使用できなくなりました。その代わり、新たに提供されるnext/navigationのフックを使用します。以下の3つのフックが提供されています。

  • useRouter()
  • usePathname()
  • useSearchParams()

これらのフックは、URLのパス名やクエリパラメータを取得する際に使用します。パス名を取得するにはusePathnameを、クエリパラメータを取得するためにはuseSearchParamsを使用します。これにより、コンポーネントが必要とするロジックだけをインポートし、コードの見通しを良くすることが可能になります。

以下のように置き換えることができます。

// Pages Routerの場合
const router = useRouter();
const pathname = router.pathname;

// AppRouterの場合
const pathname = usePathname();

ただし、これらの新しいフックはクライアントコンポーネントでのみサポートされており、Server Componentsでは使用できないことには注意が必要です。

具体的には、Server ComponentsでURLパラメータやクエリパラメータを使用するには、そのパラメータをコンポーネントのプロップとして受け取るようにします。以下の実装例では、PageコンポーネントがparamsとsearchParamsの二つのプロップを受け取るようになっています。

export default function Page({
  params,
  searchParams,
}: {
  params: { slug: string }
  searchParams: { [key: string]: string | string[] | undefined }
}) {
  return <h1>My Page</h1>
}

paramsはURLパラメータを表し、searchParamsはURLのクエリパラメータを表します。これらのプロップスを適切に使用し、各フックとの使い分けを理解することも重要だと感じました。

6. データ取得メソッドの移行

Pages Routerでは、getServerSidePropsgetStaticPropsを使用してページのデータを取得していました。App Routerでは、これらのメソッドはfetchによって単純化され、よりシンプルなAPIに置き換えられています。

以下にその例を示します。

export default async function Page() {
  // `getStaticProps`と同様(手動で無効化するまでキャッシュされる)
  const staticData = await fetch(`https://...`, { cache: 'force-cache' })

  // `getServerSideProps`と同様(毎回新たに取得される)
  const dynamicData = await fetch(`https://...`, { cache: 'no-store' })

  // `getStaticProps`の`revalidate`オプションと同様(10秒間キャッシュされる)
  const revalidatedData = await fetch(`https://...`, {
    next: { revalidate: 10 },
  })

  return <div>Hello</div>
}

上記のコードから分かる通り、新しい方法ではfetchを用いて、必要なオプションを指定するだけで、柔軟なデータ取得が行なえます。

私の個人サイトでは、24時間ごとにブログ記事のフィードを取得し表示するページがありますが、このページの移行もスムーズに行うことができました。

元々のページではaxiosのgetメソッドを使用していたので、それを直接fetchに置き換えることも可能ですが、データ取得メソッドをすべて置き換えるのは手間がかかります。そこで新たに提供されたRoute Segment Configを活用することで、データ取得のメソッドを変えることなく、revalidateの期間を設定します。

具体的には、データ取得を行っているファイルにexport const revalidate = 86400;を追加することで、データ取得が1日おきに行われるように設定できます。

いかに具体的なコードの変更例を示します。

// Pages Router
export const getStaticProps: GetStaticProps = async () => {
  const posts = await fetchRssFeeds();
  const error = posts.length === 0 ? '投稿が取得できませんでした。' : null;

  return {
    props: {
      posts,
      error,
    },
    revalidate,
  };
};

type Props = {
  posts: Post[];
  error: string | null;
};

const Posts: NextPageWithLayout<Props> = ({ posts, error }) => {
  return (
    <>
      {error ? (
        <p>{error}</p>
      ) : (
        <BlogList posts={posts} />
      )}
    </>
  );
};

export default Posts;

以下が移行後のコードです。

// App Router
export const revalidate = 86400; // revalidate this page every 1 day

const Posts = async () => {
  const posts = await fetchRssFeeds();
  const isError = posts.length === 0;

  return (
    <>
      {isError ? (
        <p>投稿が取得できませんでした。</p>
      ) : (
        <BlogList posts={posts} />
      )}
    </>
  );
};

export default Posts;

fetchを使用していないプロジェクトでも、Segment Route Configの機能を活用することで、スムーズに移行が可能となります。これで最小限の変更でPages RouterからApp Routerへの移行が実現できました。

7. スタイリング

最後にスタイリングに関して行った変更について説明します。

TailwindCSSを使用していたため、tailwind.config.jsファイル内のcontentappディレクトリを追加しました。

module.exports = {
  content: [
    './app/**/*.{js,ts,jsx,tsx,mdx}', // <-- この行を追加
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
  ],
}

これ以外の変更は必要ありませんでした。楽で良かったです。

App Routerへ移行した感想

シンプルなWebページだったこともあり、思った以上にスムーズに移行することができました。今回の移行作業を通じて、App RouterとPages Routerの違いについて理解が深まったように感じます。

特に、App Routerを使うことで、ディレクトリ構成が以前よりも明確になり、ページのコンポーネント、hooksやLayoutなどが一箇所にまとめられたことは大きなメリットに感じています。また、ISR、SSR、SSGの切り替えが単純にfetchのオプションを書き換えるだけで可能になった点も、開発効率を向上させる要素だと思いました。

一方で、今回の移行作業ではServer Componentsの活用についてはあまり深く触れることができませんでした。これは、次回以降の課題として引き続き取り組んでいきたいと思います。

今回紹介した移行手順が参考になれば幸いです。

参考

https://nextjs.org/docs/pages/building-your-application/upgrading/app-router-migration

GitHubで編集を提案

Discussion