はじめに
Next.js で、 Pages Router から App Router への移行を検証していて、移行時に検討が必要そうなバグに遭遇したのでその原因と現時点( 2024/08 時点)での対処方法をまとめました。
TL;DR
- App Router を使用している
- pageExtensions を使用している
- 独自の 404 ページを表示している
この3つの条件を満たすと、存在しないページへアクセスした場合、エラーになってしまいます。
Next.js のバージョンは 13.5.6
, 14.2.5
( 2024/08 時点での latest
) , 2024/08 時点での canary
でこのバグが発生することを確認しています。
現時点( 2024/08 時点)で App Router では、pageExtensions を使用すると独自の 404 ページが出せません。
独自の 404 ページを表示するには以下のどれかの手段を採る必要があります。
- pageExtensions に
".ts"
を追加し、 404 ページだけ.ts
で運用する - pageExtensions を諦める
- Next.js のバージョンを
13.5.6
くらいまで下げて、限定的な pageExtensions を使用する( 原因 - v13.5.6 の章記述例) - App Router を諦めて Pages Router にする
原因としては、Next.js 内部の独自の 404 ページを出す際の処理が pageExtensions を考慮した条件分岐になっていないためでした。
issue は上がっているので、修正されるまでは上記の対応しかなさそうです。
前提
- App Router を使用している
- pageExtensions を使用している
- 独自の 404 ページを表示している
この3つの条件で発生すると思われます。
Next.js のバージョンは 13.5.6
, 14.2.5
( 2024/08 時点での latest
) , 2024/08 時点での canary
でこのバグが発生することを確認しています。
App Router とは
App Router とは、 Server Components 、Streaming with Suspense 、Server Actions といった React の最新機能を使ったアプリケーション構築のための Next.js の新しいルーティング方法です。
これとは別に Next.js には、 App Router 発表以前から Pages Router というルーティング方法がありますが、 Next.js 開発元の Vercel としては Pages Router よりも App Router に注力しています。なので 私のいるプロジェクトでは Pages Router を 使用していますが、将来を見据えて App Router への移行を検討していました。現在は 1 ページだけ試験的に App Router に移行しています。
pageExtensions とは
これは next.config.js
で設定できる機能であり、ページとして表出するファイルを選ぶことができます。
App Router はデフォルトでは app ディレクトリ内の page.tsx
というファイルのみページとみなされますが、他のファイル名もページとしてみなしたい場合に有効です。
例えば以下のように、環境変数と一緒に使うことで開発中のページは開発環境にだけ表示させ、本番環境ではアクセスできないようにすることが可能です。
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: process.env.APP_ENV === 'development'
? ['page.tsx', 'page.dev.tsx']
: ['page.tsx'],
};
module.exports = nextConfig;
この活用例は App Router に限らず Pages Router にも有効です。
また、Pages Router は App Router と異なり、デフォルトでは pages ディレクトリ配下のファイルが全てページとみなされます。
そこで以下のような pagesExtensions とディレクトリ構造を用いて、コンポーネントと近いところにテストや Story のファイルを置きつつ、ブラウザなどのクライアントから page.page.tsx
以外へのアクセスを防ぐことができます。
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ["page.tsx"],
};
module.exports = nextConfig;
src/
└── pages/
├── page.page.tsx
└── page.stories.tsx
└── page.test.ts
独自の 404 ページ とは
App Router では、 not-found.tsx
を app/
配下に置くことで、独自の 404 ページを出すことができます。
HTTP Status が 404 の時 not-found.tsx
に書いたものがページとして表示されます。
このファイルを用意しない場合は Next.js デフォルトの 404 ページが出ます。
ですがほとんどの場合、 Next.js デフォルトの 404 ページを使わずサービス独自の 404 画面を出すことが多いのではないでしょうか。
バグの事象説明
前置きが長くなってしまいましたが、
- App Router を使用している
- pageExtensions を使用している
- 独自の 404 ページを表示している
この3つの条件を満たすと、存在しないページへアクセスした場合、エラーになってしまいます。
ではバグが発生した時のファイルをそれぞれ見てみます。
.
├── src/
│ └── app/
│ ├── layout.page.tsx
│ ├── page.page.tsx
│ └── not-found.page.dev.tsx
└── next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ["page.tsx", "page.dev.tsx"],
};
module.exports = nextConfig;
import Link from "next/link";
export default function NotFound() {
return (
<>
<h2>Original Not Found Page</h2>
<Link href="/">Return Top</Link>
</>
);
}
このような pageExtensions を適応して、 404 のファイル名が not-found.page.dev.tsx
という拡張子にして dev モードで起動してみたところ以下のような画面になりました。
Next.js のバージョンによりエラー時の文言が違いますが、エラーが起きるケースはどちらも App Router を使用していて、 pageExtensions 設定済み・独自の 404 ページを使用している場合です。
バグの原因
私が確認した Next.js v14.2.5
と v13.5.6
でエラー時の該当ファイルが異なっていたため、それぞれ見ていきます。
v14.2.5
このバージョンのエラーについては issue の解説 を引用しています。
// TODO: fix this typescript
const clientReferenceManifest = renderOpts.clientReferenceManifest!
ここで clientReferenceManifest
に対して Non-null assertion を使用していますが、この変数を使用している箇所で undefined になり得るのでランタイムエラーになっていました。
const serverStream = ComponentMod.renderToReadableStream(
<ReactServerApp tree={tree} ctx={ctx} asNotFound={asNotFound} />,
clientReferenceManifest.clientModules, // ココ
{
onError: serverComponentsErrorHandler,
nonce,
}
)
補足
clientReferenceManifest
が何者なのか見たところ、以下のようなオブジェクトでした。
export type ClientReferenceManifest = {
readonly moduleLoading: {
prefix: string
crossOrigin: string | null
}
clientModules: ManifestNode
ssrModuleMapping: {
[moduleId: string]: ManifestNode
}
edgeSSRModuleMapping: {
[moduleId: string]: ManifestNode
}
entryCSSFiles: {
[entry: string]: string[]
}
entryJSFiles?: {
[entry: string]: string[]
}
}
ちなみにここからは私が調査した内容の補足で、上記部分を解決してもnext.js/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts
の 351 行目部分も修正しないと正しく動かなそうでした。
if (
name === `app${UNDERSCORE_NOT_FOUND_ROUTE_ENTRY}` &&
bundlePath === 'app/not-found'
) {
ここの bundlePath === 'app/not-found'
が pageExtensions を考慮されていませんでした。
以下が bundlePath
を出力してみた結果です。
# ======================================
# .
# └── src/
# └── app/
# └── not-found.page.dev.tsx
# ======================================
# pageExtensions: ["page.tsx", "page.dev.tsx"],
# console.log("bundlePath:", bundlePath)
bundlePath: app/not-found.page.dev
判定条件が 'app/not-found'
と完全一致しているか、なので pageExtensions のことは全く考慮されていなさそうです。
この場合だと not-found.page.tsx
のファイル名でもエラーになってしまいます。
この if 文に入ると ./.next/server/app/_not-found/
に page_client-reference-manifest.js
を生成するので、誤った判定条件だとこの必要なファイルが生成されずエラーになると予想されます。
v13.5.6
こちらは next.js/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts
の 424 行目部分 が原因です。
以下の if 文に入ると、 404 エラー用の設定ファイルを読み込んで適切な処理をしてくれます。
if (/^app\/_?not-found(\.[^.]+)?$/.test(entryName)) {
manifestEntryFiles.push(this.dev ? 'app/not-found' : 'app/_not-found')
}
/^app\/_?not-found(\.[^.]+)?$/.test(entryName)
これの正規表現が pageExtensions の一部パターン考慮漏れです。
(\.[^.]+)?
なので、 (\.[^.]+)
が 0 回か 1 回の時しか true になりません。
entryName には app/not-found.page.dev
が入っています。
.page.dev
が (\.[^.]+)?
に当てはまらないのでこの if 文は true になりません。
いくつかのケースを出力してみましょう。
const regexp = /^app\/_?not-found(\.[^.]+)?$/
console.log(regexp.test("app/not-found")) // true
console.log(regexp.test("app/not-found.page")) // true
console.log(regexp.test("app/not-found.page.dev")) // false
console.log(regexp.test("app/not-found.page.dev.hoge")) // false
この結果を見ると、v13.5.6
では v14.2.5
と違いドットが 1 つまでなら許容されることがわかります。
つまり、 pageExtensions に記載して有効なのは以下のようにドットが 1 つまでということになります。
// こちらは有効
pageExtensions: ["page.tsx", "hoge.tsx", "ts"],
// ドットが2つ以上入っているとエラーになってしまう
pageExtensions: ["page.dev.tsx", "hoge.huga.tsx", "hoge.huga.piyo.tsx"],
まとめ
Next.js のバージョンによりエラーの発生箇所は異なりますが、エラーになるケースはいずれも
- App Router を使用している
- pageExtensions を使用している
- 独自の 404 ページを表示している
で一致していることがわかりました。
私が調べたバージョンは v13.5.6
, v14.2.5
, canary( 2024/08 時点 )
だけなので、もしかしたら他のバージョンでは大丈夫な可能性はあります。
なので、現状の解決方法としては
- pageExtensionsに
".ts"
を追加し、 404 ページだけ.ts
で運用する - pageExtensions を諦める
- Next.js のバージョンを
13.5.6
くらいまで下げて、限定的な pageExtensions を使用する( 原因 - v13.5.6 の章記述例) - App Router を諦めて Pages Router にする
のどれかで逃げるか、修正されるのを祈りましょう。
バグ解説がメインだったので解決方法については列挙しただけでしたが、 1 つ目の「 pageExtensionsに ".ts" を追加し、 404 ページだけ .ts で運用する」について少し補足します。
こちらは issue に workaround として書かれていたもの で、404 ページだけ not-found.ts
として運用する方法です。
コメント主の azu さんが サンプル を載せてくれているので、そちらを見るとわかりやすいです。
今回紹介したバグの他に、App Router と Pages Router が共存した状態(移行途中)で起きるバグもあるので、Pages Router から App Router へ移行するのは個人的にまだちょっと怖いです。
(参考: とろろこんぶろぐ - App Router 移行時に 0.01 % の確率で CSR 遷移が 404 エラーになる)
ですが React の最新機能を最大限活かすために、いつか App Router へ安心して移行できる日が来ることを願っています。