記事一覧に戻る

Next.js13のApp Routerを試してみたぞ!

はじめに

Next.js 13で、ReactのServer Componentを使ったApp Routerが紹介されました。従来のPages Routerに代わる機能で、大きな注目を集めていましたね。

一定の制約があるものの、サーバ側の処理にもReactのComponentが使えるようになり、React系アプリの課題であったJavascriptのバンドルサイズの削減が可能になることも、注目を集めた理由の1つかと思います。

そんなApp Routerですが、2023年5月のバージョン13.4へのアップデートにより、ベータからstable(安定)になりました。もっと時間がかかると思っていましたが、さすが早いですね。ついていくのが大変です!

私も一足遅れましたが、開発環境ではApp Routerへの移行が完了しました(ビルド時のメモリが足らず本番移行はまだできていませんが、、、)。実際に開発環境を移行してみて、構築方法は今までとだいぶ変わると感じました。

私のように最近Next.jsに触れた方にとって、App Routerのような新しい機能は少し手を出しにくいと思います。私の移行の方法や、移行の際の注意点を共有しますので、参考にしていただけると幸いです。

結構な分量になってしまったので、全部読むのが面倒な場合は目次から必要なところを見てください。

もちろん、従来のPages機能もサポートされているので、移行しないというのも1つの選択です。開発元のVercel社も、予見可能な将来で廃止の予定はないと言っていますしね。様子を見て、もっと情報が集まるようになってから移行するのも良いかと思います。

2023.09.23追記:本サイトも7月に本番移行が完了しました!

この記事の内容

App RouterではServer Componentsの導入だけでなく、サーバサイド・レンダリングの方法も変わっています。さらに、metaタグの追加も新しい方法が追加されていますし、favicon等の画像からmetaタグを自動生成する機能等も追加されています。

加えて、layout.js、loader.js、error.js、page.jsといった特殊ファイルも導入されました。ファイルベースのRoutingであることに変わりはありませんが、従来のPages Routerとは考え方や実装方法は異なります。要は今までとは色々変わっているということです。

全てはカバー出来ませんし、私もまだ試せていない機能もあります。今回は以下の内容を解説します。

  • 特殊ファイル(layout.js、page.js)
  • Metadataについて
  • Server ComponentとClient Component
  • Data Fetching

1点目の特殊ファイルは、拡張子は.js、.jsx、.tsxが利用できます。以降の説明では、なるべく.jsxに統一します。

前提知識

Next.jsのPages Routerを利用したことがある方を前提に記載しています。

App Routerのルーティング

Pages Routerではpagesの名前でフォルダを作成しましたが、App Routerではプロジェクト直下にappフォルダを作成します。

ベータ版ではnext.config.jsの設定を変更する必要がありましたが、バージョン13.4以降はstableになったのでその必要はなくなりました。

Pages Routerでは、/pagesフォルダ直下の.jsx/.tsxファイル名が直接URLのルートとして解釈されていました。例外として、index.jsxはそのフォルダ名がルートとして解釈されます。

例えば、/pages/blog/food.jsxhttps://ドメイン/blog/foodのページになり、/pages/blog/index.jsxならhttps://ドメイン/blogのページになるといった具合です。

App Routerも基本は同じです。ただ、ページとして解釈されるのはpage.jsxのみです。

例えば、/app/blog/food/page.jsxhttps://ドメイン/blog/foodのページになり、/app/blog/page.jsxならhttps://ドメイン/blogのページになります。

Pages Routerでいうところの、index.jsxと同じ扱いと考えて問題ないと思います。

他のファイルはルーティングには影響しないので、部品化したコンポーネントもappフォルダ内に配置できます。結構便利だと感じます。

特殊ファイル:layout.jsx

layout.jsxについて

各ページに共通する部品は、layout.jsxの名前で、レイアウトとして定義することができます。appフォルダ内の各階層に配置可能です。

また、appフォルダ直下では、必ずlayout.jsxを配置する必要があり、htmlタグとbodyタグを記述する必要があります。

Pages Routerにあった_document.jsや_app.jsは廃止され、/appフォルダ直下のlayout.jsxがその代わりになります。

ちなみに、このサイトでは、フッターをappフォルダ直下のlayout.jsxで定義しています。

こんな感じです。

// layout.tsx
// App専用 component
import GA from "./component/GA"
import JSON_LD from "./component/JSON-LD";
// Pagesと共通component
import Footer from "../component/Footer";
// その他
import "./globals.css";
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "全力君。",
  description: "/*省略*/",
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <GA />
      <body>
        <JSON_LD />
        {children}
        <Footer />
      </body>
    </html>
  );
}

パラメタについて

layoutは、パラメタとして実際のpage.jsxでexportされるコンポーネントを受け取ります。そのため、にlayoutの中で展開してあげる必要があります。上記の例ではchildrenがそれです。

また、ルーティングを動的に行う場合等を除き、layoutは他のパラメタを受け取ることが出来ません。基本的には固定で表示されるもののみに対応していると考えたほうが良いと思います。

layoutのネストについて

レイアウトは、appフォルダ内の各フォルダに1つ配置できます。ページごとにレイアウトを追加することができる訳です。ただ、上位階層のlayout.jsxにネストして適用されるため、厳密にはレイアウトが変更される訳ではありません。

例えば、以下のような階層になっている場合を考えます。

app/
│  layout.jsx
│  page.jsx
│
└─blog/
    layout.jsx
    page.jsx

ここで、/blogにアクセスした場合、実際には以下のようなイメージでコンポーネントが構築されます。

<app直下のlayout>
  <blog直下のlayout>
    <blog直下のpage />
  </blog直下のlayout>
</app直下のlayout>

前述のとおり、下位階層のlayoutは、上位階層のlayoutにchildrenとして展開されます。

ネストされては困る場合、Route Groupsで制御することも可能です。私もまだ試せていないため割愛します。

特殊ファイル:page.jsx

pages.jsxが実際に表示されるページです。従来のPages Routerではpagesフォルダ内の.jsxファイルは全てページとして解釈されましたが、App Routerではpage.jsxのみがページとして解釈されます。

記述内容は従来とほとんど変わりません。ページとして表示するコンポーネントをdefault exportすればOKです。

// page.jsx
export default function Page() {
  return (
    <h1>hello,world</h1>
  )
}

metadataについて

metadataの使い方

Pages Routerでは、ページのタイトルやmetaタグ情報はnext/headHeadコンポーネントで設定していました。

App Routerでは、metadataオブジェクトをexportすることで設定します。page.jsxもしくはlayout.jsxでexportできます。

例えば、appフォルダ直下のlayout.jsxで以下のようにmetadataをexportしたとします。page.jsxではmetadataのexportはしていないものとします。

// /app/layout.tsx
export const metadata = {
  title: 'Root Page (from root layout.tsx)',
  description: 'Generated by Next.js',
}

export default function RootLayout({children,}: {children: React.ReactNode}) {
  return (
    <html lang="ja">
      <body>
        {children}
      </body>
    </html>
  )
}

実際にページを開いてコンソールを確認すると、titleやmetaタグが設定されているのが分かります。charsetやviewport等は勝手に追加してくれます。

※一部省略しています。

<head>
  <title>Root Page (from root layout.tsx)</title>
  <meta charset="utf-8">
  <meta name="description" content="Generated by Next.js">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

metaタグに設定する内容は、基本的にはmetadataで全てまかなえると思います。

上記の例だとmetadataは固定値ですが、generateMetadata関数を使って動的に生成することも可能です。

詳しくはドキュメントをご確認ください。

metadataのネスト

前章のとおり、layoutはネストされます。上位階層のlayout.jsxにもmetadataが定義されている場合、重複する項目については下位階層の項目値で上書きされます。 上位階層にのみ存在する項目は、そのまま上位階層の値が適用されます。

例えば、以下のようなフォルダ構成で、それぞれのlayout.tsxでmetadataをexportした例を考えます。

app/
│  layout.tsx
│  page.tsx
│
├─blog-test/
│    page.tsx
│    layout.tsx
  • app直下のlayout.tsx
export const metadata = {
  title: 'Root Page (from root layout.tsx)',
  description: 'Generated by Next.js',
}

export default Layout(){/*省略*/}
  • blog-test直下のlayout.tsx
export metadata = {
    creator: "zenryoku-kun",
    title: "app-test"
}

export default Layout(){/*省略*/}
  • 実際に出力されるhtml(抜粋)
<head>
  <meta name="description" content="Generated by Next.js">
  <meta name="creator" content="zenryoku-kun">
  <title>app-test</title>
</head>

titleは下位階層のblog-testのlayoutの値で上書きされ、下位階層で定義しなかったdescriptionは上位階層の内容が表示されていることが確認できます。また、下位階層で追加したcreatorも適用されていることも分かります。おそらく内部的に{...上位階層metadata、...下位階層metadata}のようなマージがされているものと思います。

layoutでmetadataを定義する場合はこの動きを把握しておいたほうがよさそうです。

なお、page.jsxでmetadataを定義した場合、同じ項目は「page > 同階層のlayout > 上位階層のlayout 」の順番で優先されます。

Server Component

Server Componentは、サーバ側では利用できるReactのコンポーネントです。ビルド時など、リクエスト前にレンダリングが可能なため、クライアントのJavaScriptのバンドルを減らすことが可能です。

Next.js 13の目玉機能ですが、そこまで構える必要はありません。何故なら、App Routerを利用する場合、コンポーネントは全てServer Componentとして解釈されるからです。layout.jsx、page.jsxといった特殊ファイルも同様です。

しかし、Server Componentではclickやmousemoveのようなブラウザのイベントや、useStateやuseEffectといったReactのフックは利用できません。Server Componentが対応していない機能を使う場合、Client Componentを利用していくことになります。

ServerとClientの使い分けは、公式の比較表がわかりやすいです。

nextjs-bug

さりげなくReactのClassコンポーネントが非対応になってます。

Client Component

Client Componentの使い方

Client Componentにするには、.jsxファイルの先頭に"use client"をつけるだけです。他は、基本的には今までのReact Componentと同じです。

"use client"
import MyWrapper from "./MyWrapper"
import {useState} from "react";

export default function Dog(){
  const [dog,setDog] = useState("chiwawa")
  return (
    <MyWrapper>{dog}</MyWrapper>
  );
}

Client Componentのパフォーマンスは、公式ドキュメントによるとServer Componentよりは劣り、クライアント側のJavascriptのバンドルサイズも大きくなるとのことです。 とはいえ、Next.jsでの取り扱いは、今までのコンポーネントと同じです。デフォルトではサーバ側でpre-renderingされるので、App Routerへの移行が最優先事項であれば、そこまで気にしなくて良い気がします。

Client Component内のimport

"use client"したファイル内のimportは、全てクライアントのバンドルとして解釈されます。これは、外部のパッケージであっても、他のコンポーネントであっても何でもです。上記の例だと、自作のコンポーネントMyWrapperをimportしていますが、これもクライアント側にバンドルするものとして扱われます。そのため、"use client"は最上位のClient Componentに1回記述すればOKです。"use client"したファイルが、ServerとClientの境界線になります。

ここで注意点なのですが、importしているファイルの中でも、Node.js環境でしか動かない(ブラウザで動かない)パッケージがあると、エラーになってしまいます。

上記の例で、MyWrapper内でNode.jsの標準パッケージのfsをimportした時のエラーです。

./app/blog-test/MyWrapper.tsx:2:0
Module not found: Can't resolve 'fs'
  1 | import { useEffect } from "react";
> 2 | import { readFile } from "fs";
  3 |
  4 | async function get() {
  5 |     readFile("./page.tsx", data => console.log(data))

https://nextjs.org/docs/messages/module-not-found

fsが解決できないと怒られています。まぁ、ブラウザではfsのようなNode環境でしか動かないモジュールは利用できない、と考えれば当然かもしれません。

開発環境(npm run dev)で動かしている場合、実際にページを開かないとエラーが出ないので気が付きにくいです。

importできるモジュールについて

上記の注意点とも関連します。

Pages Routerでは、getServerSidePropsのようなデータ取得関数に、Node環境でしか動かないモジュールの利用が出来ました。ある意味、クライアント側、サーバ側の処理が同じファイル内に混在している状態だったと言えます。

App Routerでは、Server ComponentとClient Componentに明確に区別されます。Client Componentでは、Node環境でしか動かないモジュール(fsやpath等のNode固有のモジュールや、remarkやrehypeのようにクライアントで直接実行できないモジュール)はimportできません。

"use client"したファイルでimportしたコンポーネントは「クライアント側」、うっかりしていると上記のModule not found: Can't resolve 'XX'が出てしまいます。

もしこのエラーが出たら、"use client"しているファイルと、そこでimportしているコンポーネントでNode環境でしか実行できないファイルが含まれていないか確認してみてください。

Client Componentの位置について

ドキュメントでは以下のように案内されています。

To improve the performance of your application, we recommend moving Client Components to the leaves of your component tree where possible.

コンポーネントをネストする際は、なるべくClient Componentを深い位置に置けとのことです。上述のとおり、Client ComponentにネストされたComponentは全てクライアント側のバンドルとなってしまいます。Server Componentの恩恵をなるべく増えるように、必要な箇所のみClient Componentにして、なるべく深く置けということでしょう。

Server ComponentとClient Componentの境界

Server ComponentにClient Componentをネストすることが出来ますが、Client Componentにpropsとして渡せるデータに制約があります。

Props passed from the Server to Client Components need to be serializable. This means that values such as functions, Dates, etc, cannot be passed directly to Client Components.

上記のようにドキュメントでは記載されています。serializableである必要がある、とのことです。JSONでパースできるデータならOKです。Pages RouterのgetStaticsPropsのような、サーバサイド・レンダリングの関数と同じですね。

試しに関数をServer ComponentからClient Componentに渡してみました。

  • Client.tsx(Client Component)
"use client"

export default function Client({ fn }: { fn: () => void }) {
  return (
    <>
      <h1>Server Componentから関数を受け取ってみる</h1>
      <button onClick={fn}>click me!</button>
    </>
  )
}
  • page.tsx(Server Compoent)
import Client from "./Client"

function fn() {
  console.log("hello,world")
}

export default async function () {
  return (
    <Client fn={fn} />
  )
}
  • エラー内容
 error Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
  <... fn={function}>
          ^^^^^^^^^^
    at stringify (<anonymous>)

開発環境では、ページを開いたタイミングでエラーになりました。メッセージは分かりやすいですね。ただ、実際にページを開かないとエラーに気が付けません。記述した時点でエラーが分かるようにしてくれるとありがたいです。

Server Componentをimportするパターン

ドキュメントでは、以下の記述があります。

Unsupported Pattern: Importing Server Components into Client Components

Recommended Pattern: Passing Server Components to Client Components as Props

Client ComponentsからServer Componentsをimportするのはサポートしないとのことです。代わりに、「Server ComponentをPropsとしてClient Componentに渡す」パターンが推奨されています。

これは若干不可解ではあります。Client ComponentからimportしたComponentは全てClient Componentとして扱われるとドキュメントに記載されていますので。現に、サーバサイドでしか動かないパッケージ等がimportされていない限り、問題なく動きます。

これは、おそらくServer Componentsを利用できる箇所ではなるべく利用してもらえるように、「サポートしない」というスタンスを取っているものと考えています。ネストが深くなると、上述のエラーの判別が難しくなるというのも理由の1つかもしれません。いずれにしても、「Client ComponentでimportできないServer Component」があるのは事実なので、なるべくこのパターンを踏襲したほうが良いかと思います。

  • サポートされないパターン
// page.tsx
"use client"
import { useEffect } from "react";
import ServerComponent from "./Server";

export default async function () {
  useEffect(() => console.log("running"), [])
  return (
    <ServerComponent />
  )
}
// Server.tsx
export default function ServerComponent() {
  return (
    <div>I am a Server Component</div>
  )
}
  • 推奨パターン

Server.tsxは変更ありません。Client Componentから直接importするのではなく、childrenとしてコンポーネントを受け取るようにしておき、Server Componentを渡すのがミソですかね。私もこの使い方はあまりしませんが、Server/Client Component抜きに、昔からReactでは良く使うようなので覚えておきたいです。

// page.tsx
import ServerComponent from "./Server";
import ClientComponent from "./Client";
export default async function () {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

// Client.tsx
"use client"
import { useEffect } from "react";

export default function ClientComponent(
  { children }: { children: React.ReactNode }
) {
  useEffect(() => console.log("running"), [])
  return (
    <>
      {children}
    </>
  )
}

Data Fetching

データ取得方法

データ取得する方法も強化されています。Pages Routerでは、主に3通りあったと思います。

  1. クライアント側で取得(fetch)
  2. ビルド時にサーバ側で取得(getStaticProps)
  3. リクエスト時にサーバ側で取得(getServerSideProps)

App Routerでは、Server Componentsが導入されたことにより、上記の2,3がより柔軟に実行できるようになっています。今までは所定の関数でしかデータの取得ができませんでしたが、基本どこでもasync/await関数で取得が可能です。

サーバ側でもfetch関数が新たに実装されており、使える箇所ではこれを使うのが良いようです。自動で取得したデータをキャッシュするため、同じデータを複数回取得する場合に効率良く処理してくれるとのことです。

Static Data Fetching

これがPages Routerで言うところのgetStaticPropsです。fetchでデータを取得する非同期関数を作成し、コンポーネントで呼び出すだけです。関数名は好きな名前で大丈夫です。

/*データを取得する非同期関数*/
async function getProps() {
  // fetchがサーバでも動くように。デフォルトではstatic fetching。
  const data = await fetch("http://localhost:3001/fetch-test")
  if (!data.ok) {
    throw new Error("...")
  }
  return data.json()
}

export default async function () {
  const data = await getProps();
  return (
    <div>
      {/*data展開*/}
    </div>
  );
}

fetchのオプションで、{next:{revalidate:10}}のようにキャッシュの更新間隔(秒)を指定することも可能です。

Dynamic Fetching

Pages Routerで言うところのgetServerSidePropsです。リクエスト毎にデータが取得されます。

async function getDynamicProps() {
  const data = await fetch("http://localhost:3001/fetch-test", { cache: "no-store" })
  if (!data.ok) {
    throw new Error("...")
  }
  return data.json()
}

export default async function () {
  const data = await getProps();
  return (
    <div>
      {/*data展開*/}
    </div>
  );
}

static data fetchingとの唯一の違いは、fetchのオプションでcache:"no-store"を指定しているところです。キャッシュの保存がされないため、リクエスト毎にデータの取得が行われます。主にリクエスト時点の内容を表示したい時に使います。

fetchが使えない場合

サーバ側でfetchが使えるようにしてくれても、httpでリクエストを投げることに違いはありません。そんなに都合よく、外部のAPIでデータ取得出来ないかと思います。 例えば、このページもmdファイルからデータを取得しているので、fetchというより、サーバのファイルがデータの源泉になります。

私も一瞬困ってしまいましたが、ちゃんとfetchが使えない場合の方法も案内されていました。というより、非同期関数なら別にfetchを使わなくても問題ありません。

ページと同じ階層にあるmdファイルを取得し展開する例だと、以下のような形になります。

import fs from "node:fs/promises";
import path from "node:path";

async function getPropsWithoutFetch() {
  const root = path.resolve();
  const dir = path.join(root, "app/fetch-test", "test.md")
  const data = await fs.readFile(dir, { encoding: "utf-8" })
  return data;
}

export default async function () {
  const data = await getPropsWithoutFetch();
  return (
    <div>
      {data}
    </div>
  )
}

fetchは使っていませんが、これもStatic Data Fetchと同じ扱いになります。ただし、fetchを使った時のようにキャッシュはしてくれません。別のコンポーネントで同じデータを取得する場合、キャッシュを使わずに再度同じ処理でデータ取得するため、ビルド時のパフォーマンスは少し下がってしまうかもしれません。まだ私も試せていませんが、回避策として、Reactのcache関数を使うことでキャッシュさせることが出来るようです。

fetchを使わずに、Dynamic Fetchingをしたい場合はexport const revalidate = 0を追加すればOKです。この定数revalidateはRoute Segment Configと呼ばれ、手動でページやレイアウトの挙動を制御することができます。revalidate以外にもあるので、詳細はリンク先をご確認ください。

TypeScript利用時の注意点

async関数で定義したコンポーネントを利用する場合、TypeScriptのバージョンによってはエラーになる場合があります。

※ TypeScriptのバージョンが5.1.3以降で、@types/reactが18.2.8以降の場合はこのエラーは出ません。

例えば以下のコンポーネントをpage.tsxで利用したとします。

  • コンポーネント
// Comp.tsx
async function getData() {
  return [
    { name: "John", age: 30 },
    { name: "Kate", age: 25 }
  ]
}

export default async function AsyncComponent() {
  const data = await getData();
  return (
    <div>
      {data.map(v => {
        return (
          <div>
            {`name:${v.name} age:${v.age}`}
          </div>
        );
      })}
    </div>
  )
}
  • page.tsx
import AsyncComponent from "./Comp";

export default async function Page() {
  return (
    <>
      <h1>TypeScriptテスト</h1>
      <AsyncComponent />
    </>
  )
}
  • TypeScriptのエラー
'AsyncComponent' cannot be used as a JSX component.
Its return type 'Promise<Element>' is not a valid JSX element.
Type 'Promise<Element>' is missing the following properties from type 'ReactElement<any, any>': type, props, key

コンポーネント(JSX element)がPromiseに包まれているためエラーが表示されます。

  • 解決策

非同期のコンポーネントの直前に/* @ts-expect-error Server Component */を追加すればOKです。

import AsyncComponent from "./Comp";

export default async function Page() {
  return (
    <>
      <h1>TypeScriptテスト</h1>
      {/* @ts-expect-error Server Component */}
      <AsyncComponent />
    </>
  )
}

最後に

App Routerの一部の特殊ファイル、metadata、Server/Client Component、基本的なData Fetching方法を解説しました。結構なボリュームになりましたが、まだまだ紹介できていない機能も多いです。

error.jsやloader.jsといった特殊ファイルや、Parallel Data Fetching等、今回は紹介できていませんが、なかなか便利そうです。早く使ってみたいですね。

とはいえ、開発ペースが早いですね、、、新たなにベータ機能でServer Actionsなるものも出てきました。

フレームワークの最新機能を追いすぎると、時間が足りなくなりそうです。本末顛末にならないように、自分にあった距離感で付き合う必要があると感じました。

参考

記事一覧に戻る