🐨

Next.js って App Router が出てきて平和じゃなくなったよね

2024/07/22に公開6

背景

Next.js に App Router が導入されてから1年近くが経ちました。しかし、未だに App Router を前提として設計のベストプラクティスが定まっておらず、身近なフロントエンドエンジニアはみな「まだプロダクトに取り入れるには考えることが多いよね」という共通認識のまま止まっているような気がしています。

また、App Router が導入されるまでは、技術選定の無難な選択肢として Next.js が最有力でした。しかし、現在は App Router の設計のプラクティスが未発達なことや、オーバースペックであるという見方が出てきており、検討しなければならないことが多くなったように感じます。

そうした中で、ではその懸念というのはどのようなものがあり、導入しずらい要因に何があるのか、というところが、今回執筆を行う上での背景になります。

App Router導入で考えないといけないこと

では、実際に App Router の導入以降どのような変化があったのかということと、どのようなことを実装者側が考慮しなければならなくなったかをまとめていきたいと思います。今回の記事では、具体的な機能の説明や実装方法について説明するものではないため、簡単な説明に留めるということを前提にして見ていただければと思います。

ディレクトリ設計の検討

App Router の環境下では、layout.tsxtemplate.tsxという特別なファイルを作成でき、そのファイルを設計に組み込むかどうかで、ディレクトリ設計に影響が出てきます。

layout
この layout ファイルを作成することで、複数のページで表示する共通の UI を定義することができ、共通で表示したいコンポーネントをページ毎に用意する必要がなくなります。例えば、多くのページで表示されるヘッダーやサイドメニューなどがイメージしやすと思われます。

また、layout ファイルを共通で使用するページへの遷移に関しては、コンポーネントのレンダリングが行われないため、同じコンポーネントを何度も読み込み直す手間を省き、ユーザー体験向上を図ることができます。

さらに、Nesting Layouts という機能を有しており、layout ファイルを複数作成することで、その複数作成した分だけメインで表示したコンポーネントがラップされる仕組みになっています。そのため、layout ファイルを使用してネストを深くすることで、ネスト先の子ページで行う実装量を減少させることができます。

Nesting Layouts

template
説明の内容としては、layout ファイルと同じで、複数のページで表示する共通の UI を定義することができます。しかし、layout ファイルと異なる点は、ページ遷移などが発生した際にコンポーネントのレンダリングを毎度行うことにあります。これによって、クライアント側で保持している値などをページ毎にリセットしたい時に使用がされます。

https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts

具体的な懸念点としては、Nesting Layouts を考慮に入れた設計になります。
先述の通り、Nesting Layouts は、ネストを深くすればするほど、ネスト先の子ページで行う処理やコード量などを大幅に減らすことができます。一方で、パスによってネストしているかどうか判定されるため、layout ファイルで定義した UI と異なる見た目をしたページはネストさせられません。そのため、app配下のディレクトリ設計の面で多少なり気をつけないといけない要素として検討する必要があります。

また、ディレクトリ設計に関しては、後述する項目にも大きく関連してくるため、App Router 環境の最大の懸念点だと言えると思います。

以上のように、新たに layout と template というファイルが登場し、Nesting Layouts という機能が導入されたことにより、ディレクトリ設計について検討しなければならない点が増加したと言えます。

コンポーネント設計の検討

App Router の環境下では、Client Components と Server Components という概念から、コンポーネント毎にレンダリングされる環境が異なってきます。

その名の通り、Client Components はクライアント側で生成されるコンポーネントであり、Server Components はサーバー側で生成されるコンポーネントになります。それぞれのコンポーネントの特徴などを説明すると、記事一つ分になり得る量になりそうなので簡単に説明すると以下のようになります。

Client Components
今までの Pages Router 環境と近しい使用法ができるコンポーネントになります。ブラウザ側で実行される(正確にはサーバーでも実行されます)ため、状態の保持やイベントリスナーの登録などが可能(useState, useEffectなどが使用可能)で、ファイルの先頭に'use client'と追加することでクライアントコンポーネントとして定義できます。極端な話だと、全てのコンポーネントの先頭に'use client'を設定することで、今まで通りの実装を行うことができます。

Server Components
サーバー側で実行されるファイルであり、App Router 環境下ではデフォルトで設定されているコンポーネントになります。基本的には、実行速度やセキュリティ面から、クライアント側で処理するよりサーバー側で実行した方が効率が良いため、クライアントでしか処理できない要素を含むコンポーネント(状態の保持やイベントリスナーを有する場合)以外は、サーバーコンポーネントとして実装するべきとされています。

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

Server Components の具体的な説明やメリットは、過去に以下の記事にて執筆しているため、合わせてご確認いただけると幸いです。

https://zenn.dev/noko_noko/articles/7987456909978c

具体的な懸念点としては、Client Components と Server Components の挙動を意識した使い分けにあります。

これに関しては、具体的な例を示した方がわかりやすいと感じたため、以下に挙げようと思います。

  1. データ取得は Server Components で行う方が効率が良いため、データ取得の処理は Server Components に実装するべきとされています。そのため、Client Components でデータ取得を行いたい場合どのように対応するのか検討する必要があるかもしれません。

  2. Server Components は Client Components にネストさせるとuse clientを明示せずとも自動的に Client Components として認識されるため、意図せず変更させないためのルールを検討する必要があるかもしれません。

  3. UI ライブラリを使用している既存のサービスの場合、その UI ライブラリに Server Components の概念が適応されるのか、またされないならどのように対応するか、ということを検討する必要があるかもしれません。

このように、今回新たに追加された概念である Server Components の挙動や得意をしっかりと理解した上で、今まであった Client Components の概念とどのように設計面と実装面で使い分けるのかを検討しなければなりません。また、今回挙げたのはあくまで一例であり、どういった考慮をしなければならないのかということも一から検討する必要があります。

以上のように、新たに Client Components と Server Components というそれぞれの挙動を正確に把握しなければならないコンポーネントの概念が登場したことによって、コンポーネントの設計について検討しなければならない点が増加したと言えます。

Server Actions の検討

App Router の環境下では、Server Actions というサーバー上で実行される非同期関数を定義することができます。

この Server Actions は、主に HTML のform要素のaction属性で使用する機能(button要素等でも使用可能)で、データの作成・更新・削除などをサーバー側で実行することができます。そうすることで、データの作成・更新・削除に関するコードをクライアント側で保持する必要がなくなり、コード量の削減を図ることができます。また、それによってプログレッシブエンハンスメント(特定の環境下で動作しないなどを回避する)などのメリットを享受することができます。

具体的な実装方法に関しては、以下の画像で提示しているように、'use server'と記載し、サーバーで実行する関数であることを示すことで使用することができます。

Server Components Client Components
server components client components

https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

具体的な懸念点としては、各コンポーネントによる使い分けの設計とセキュリティ面の考慮にあります。

この Server Actions は、上述した Client Components と Server Components によって使用方法が異なっています。

具体的には、画像で既に示しているように Server Components の場合だと、非同期関数を定義している箇所に対して、'use server'と明示することで、その関数を Server Actions として定義することができます。一方で、Client Components の場合だと、その非同期関数を定義した新たなファイルを作成し、そのファイルの先頭に'use server'と明示を行い、その上で、その関数を Client Components で呼び出し、使用するという形になります。

そのため、非同期処理を行う箇所に対して'use server'を明示していけば良いというわけではなく、Server Actions をどのように使用していくかコンポーネント設計と合わせて検討を行う必要があります。

また、Server Actions はセキュリティ面での考慮が必要になります。以下の記事のように、本来公開してはいけないような関数でさえ、Client Components 内で使用する別ファイルに切り出す形式で実装を行なったいた場合に公開されることになります。

https://zenn.dev/moozaru/articles/b0ef001e20baaf

このセキュリティ上の問題を避けるためにも、非同期処理を行う API 周りの設計やルールの整備も含めて行わなければなりません。

以上のように、新たに Server Actions という非同期処理の実装方法が登場したことにより、コンポーネントの設計とセキュリティの考慮を行わなければならず、非同期処理の方法について検討しなければならない点が増加したと言えます。

データキャッシュの検討

App Router の環境下では、Fetch API が拡張されており、データ取得のキャッシュ周りの設定をすることができます。

この Next.js 側で拡張された fetch 関数は、通常の fetch と同じように使用できるものの、第二引数にキャッシュに関する設定を受け取ることができます。そのキャッシュの設定は、大きく分けて3つあり、それぞれ取得するデータに応じて使用用途が異なってきます。

force-cache
こちらを設定することにより、データのキャッシュを行い、リクエスト毎に一致するキャッシュがあるか確認が行われます。一致するキャッシュが存在する場合、そのキャッシュされたデータを返し、一致するキャッシュが存在しないか、キャッシュが古くなっている場合、サーバーへデータの取得を行います。

fetch(`https://...`, { cache: 'force-cache' })

no-store
こちらを設定することにより、データのキャッシュを行わず、リクエスト毎にサーバーへデータの取得を行います。簡単な説明だと、今まで通りのデータ取得を行う設定になります。

fetch(`https://...`, { cache: 'no-store' })

revalidate
こちらを設定することにより、データのキャッシュは行うものの、キャッシュの有効期間の設定を行うことができます。revalidateに設定できる値は3パターンあり、それぞれ効果としては以下のように使い分けることになります。

  • false: 無期限にキャッシュを行います
  • 0: キャッシュが行われないようになります
  • number: キャッシュ時間を秒数として設定できます
fetch(`https://...`, { next: { revalidate: false | 0 | number } })

https://nextjs.org/docs/app/building-your-application/caching#data-cache

具体的な懸念点としては、取得するデータ毎にキャッシュを行うべきかどうかの検討を行う必要性にあります。

当たり前のことではあるのですが、サーバーから取得したデータには、キャッシュに残すとセキュリティ面で問題になり得るものも含まれています。そのため、どのデータはキャッシュを行い、どのデータはキャッシュを行わないのか、というルールをチームで決める必要性があります。

また、今回は次の「レンダリング方法の検討」で必要な説明のため、fetch 関数のキャッシュについて説明を行いましたが、本来 App Router 環境でのキャッシュは他にも3つあり、それぞれのキャッシュについて理解を深める必要があります。

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

さらに、App Router で追加されたキャッシュは、データフェッチライブラリ(SWR や Tanstack Query)などで行われているキャッシュとは異なるため、それぞれの理解を深めた上で、取捨選択を行う必要があります。

そのため、データ取得に関してのキャッシュの理解だけでなく、その他も含めたキャッシュ全般に対しての理解を深める必要があります。

以上のように、新たに Fetch API が拡張され、データキャッシュが行えるようになったことにより、キャッシュを行うかどうかの判断が必要になり、合わせてその他も含めたキャッシュ全般について検討しなければならない点が増加したと言えます。

レンダリング方法の検討(SSG/SSR/ISR)

App Router の環境下では、ページのレンダリング方式である SSG/SSR/ISR の設定方法が今までと異なっており、今まで使用していたgetStaticProps getServerSidePropsは、廃止される形になっています。

では、どのように設定できるかというと、先述した Next.js に拡張された fetch 関数から、自動で判別してくれるものとなりました。具体的には、fetch 関数のキャッシュ設定方法によって判別されるようになり、ページ毎に設定するのではなく、そのページ内で行われているデータ取得のキャッシュ設定に則って決められるものとなりました。

// SSG
fetch(`https://...`, { cache: 'force-cache' })

// SSR
fetch(`https://...`, { cache: 'no-store' })

// ISR
fetch(`https://...`, { next: { revalidate: false | 0 | number } })

また、データ取得のキャッシュの設定だけでなく、以前のgetStaticProps getServerSidePropsのように明示的に設定する方法もあります。

ページのトップレベルであるpage.tsxを含むいくつかのページでは、そのページのレンダリング方法を設定するオプションが存在しており、そのオプションを設定することでレンダリング方法を明示的に定義することができます。

オプションの定義方法としては、主に使用する2つの設定があり、それぞれ定義することで SSG と SSR の設定を行うことができます。

// page.tsx

// SSGとしてレンダリングされる
export const dynamic = 'force-static'

// SSRとしてレンダリングされる
export const dynamic = 'force-dynamic'

https://nextjs.org/docs/app/building-your-application/upgrading/app-router-migration#step-6-migrating-data-fetching-methods

https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#options

具体的な懸念点としては、それぞれのレンダリング方法を組み合わせたときの挙動にあります。

それぞれのレンダリング方法の設定方法の説明を読んでいただいた方の中には、疑問に思った方も少なからずいるのではないでしょうか。例えば、以下のような実装を行った場合、どのようにレンダリングが行われるか答えられる人は少ないと感じています。

'use client'

export const dynamic = 'force-static';

export default function Parent() {
  const isr = fetch('https://pokeapi.co/api/v2/pokemon/ditto', { next: { revalidate: 1000 } })

  return (
    <div>
      Parent!
      <Child />
    </div>
  )
};

const Child = () => {
  const ssr = fetch('https://pokeapi.co/api/v2/pokemon/ditto', { cache: 'no-cache' })

  return <div>Child!</div>
}

こちら答えとしては、SSR と判定されているようでした。
もちろん、実際には今回のような実装方法は行わないと思います。ですが、検証している中でもなぜそうなるのかわからないという場面に何度も遭遇しており、この辺りの挙動を正確に把握した上で実装を行う必要があります。

以上のように、レンダリングの設定方法が変わり、その方法が複数になったことによって、それぞれのレンダリングの挙動について検討しなければならない点が増加したと言えます。

平和じゃなくなった理由

Next.js を使う上で、考えないといけないことが増えた

こちらに関しては、ここまで読んでくださった方には伝わっているような気がしますが、App Router を使いこなす上で必要な知識がかなり増えた印象があります。

また、実際にサービスに導入する場合、検討・考慮しなければならないことだけでなく、知っておかないと大きな問題につながりかねないことも含んでいる(Server Actions やキャッシュ周り)ため、容易に使えないというところも App Router 導入を遠ざけている要因だと考えられます。

さらに、チームで開発を行なっている場合、チームの誰かが知っていれば良いという状態は健全ではなく、ある程度全員が知識を持って開発する必要があります。しかし、App Router の複雑でたくさんある機能に対して、チーム全員がある程度の知識を共有することは、決して簡単なことではないと感じています。

そのため、Next.js の App Router の導入によって考える必要がある項目が増加し、導入のハードルが高くなっていることが一つの要因であると考えられます。

App Router を導入するメリットがわかりにくい

一つ前で説明させていただいた通り、App Router によって考えなければならない事が増加しました。しかし、その増えた機能の使い方を理解した上で、それを導入するメリットがわかりにくい事が挙げられます。

もちろん、それぞれ説明させていただいた機能には、明確にサービスを良くするためのメリットが存在します。しかし、そのメリットを必要とするほど、以前の Next.js に問題を感じていなかったため、検証してチームに共有して導入まで持っていくほどの熱量が持てない事が考えられます。

また、App Router で導入されたそれぞれの機能のメリットをある程度理解した上で、「App Router の導入コストに見合うメリットがあるか」と問われた時に、「あります」と明言できるかと言われると、自信がない人が多いのではないかと感じます。

少し余談になりますが、Next.js が流行った要因として、React で必要だった面倒な設定ファイルなどを良い感じにやってくれていたため、スピード感が求められる新規のサービスや、設定ファイルの知識が浅い初学者の方などが選択しやすかった事が挙げられます。

しかし、今回の Pages Router から App Router への移行は、今まで取っ付きやすかった Next.js から、色々ちゃんと考えないといけない Next.js に変わったような印象があります。

話を戻すと、課題があって解決したというよりも、今まででもよかったものをより良くしようとして複雑になったという印象が強く、選択を躊躇ってしまう要因になっているように感じました。

まとめ

この記事では、App Router に対して何となく抱いていた懸念点の検討と調査を行い、自分自身が何に対して及び腰になっているのかを整理してきました。個人的に App Router のわかっていなかったところを明確にしながら進めていたものの、この記事から何かしら学びを得る方がいると嬉しく思います。

一方で、少し目を引く誇大なタイトルと、個人の思想が幾分か入っている記事になるため、反感を抱かせてしまった方には申し訳なく思っております。また、できるだけ公式の言っていることを参考にまとめましたが、間違っている箇所があれば優しく指摘いただけると嬉しいです。

Discussion

soagssoags

分かりやすくまとまっていて素晴らしいです。

StaticExportsが動的ルーティングできないおかげで、業務システムなどのSSR要らないケースが対応できないというのもよく言われていますね。
Vite + React Router で十分というケースなんですが、各種設定を気にしなくていいNext.js がいいなぁと思っていたところに、Remix SPA Mode が来たことで揺れています。

ひろみち。ひろみち。

選択肢が増えたから、分からないんなら
いまのままでもできるのでそれでいいのでは?
悩まなくて済む。

私は選択肢ふえて嬉しいです。
わからないところは選択せずです。
そしれわかれば結構楽になる。

機能拡張とはそんなものなきがします。
改悪ではないですし。
無理や古いままではな徐々にいいのを取り入れて楽になっています。

たぬきの教祖たぬきの教祖

新しい機能は取り入れたい、習得したい。
でもベストプラクティスじゃないと怖い、或いはダサい。これがエンジニアの性であろう。その感性がないと痛い目をみることになる。
故に、便利だけど複雑なフレームワークを避ける、ことはよくあるだろう。
VueはEasyだがReactはSimple、とはよく言ったものだが、Simpleを捨てては苦しくなる。
で、いっそ違うフレームワークに、今ならSvelte、という選択肢もあるが、Nuxtという選択肢もあるのだよ(ダイマ)。今は手軽さのEasyではなく、楽のEasyではあるけれど。

y2ky2k

App Routerがstableになった頃にプロジェクトで採用したことがあったのですが、コンポーネント設計には本当に苦しめられました。データキャッシュが取り沙汰されることが多く、App Routerの辛いところはそこだけじゃないと悶々としていたので、本記事で触れていただけて嬉しい限りです。

最近はこのコンポーネント設計によって純粋コンポーネントやcompositionというプラクティスが自然と盛り込まれるようになり、テストコードや疎結合の面で恩恵がありそうだと感じています。

masakinihirotamasakinihirota

必要なときに、必要な機能を使えばいいだけだと思います。どんなものでも何年もバージョンアップを重ねてきたフレームワークの全部の機能を最初から使える人はいません。

'use client'

export const dynamic = 'force-static';

export default function Parent() {
const isr = fetch('https://pokeapi.co/api/v2/pokemon/ditto', { next: { revalidate: 1000 } })

return (
<div>
Parent!
<Child />
</div>
)
};

const Child = () => {
const ssr = fetch('https://pokeapi.co/api/v2/pokemon/ditto', { cache: 'no-cache' })

return <div>Child!</div>
}

このコードとか、たとえ例でも車の操縦で同時にアクセルとブレーキとサイドブレーキとハンドルを切って、車の操縦は難しいと言っているようなものです。