【Next.js】AppRouter の not-found ページは pageExtensions を考慮してくれない

# はじめに
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` で運用する](https://github.com/vercel/next.js/issues/65447#issuecomment-2294801418)
- pageExtensions を諦める
- Next.js のバージョンを `13.5.6` くらいまで下げて、限定的な pageExtensions を使用する( 原因 - v13.5.6 の章記述例)
- App Router を諦めて Pages Router にする

原因としては、**Next.js 内部の独自の 404 ページを出す際の処理が pageExtensions を考慮した条件分岐になっていないため**でした。

[issue](https://github.com/vercel/next.js/issues/65447) は上がっているので、修正されるまでは上記の対応しかなさそうです。

# 前提
- 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 の新しいルーティング方法です。

https://nextjs.org/docs/app

これとは別に Next.js には、 App Router 発表以前から Pages Router というルーティング方法がありますが、 Next.js 開発元の Vercel としては Pages Router よりも App Router に注力しています。なので 私のいるプロジェクトでは Pages Router を 使用していますが、将来を見据えて App Router への移行を検討していました。現在は 1 ページだけ試験的に App Router に移行しています。

## pageExtensions とは
これは `next.config.js` で設定できる機能であり、ページとして表出するファイルを選ぶことができます。

https://nextjs.org/docs/pages/api-reference/next-config-js/pageExtensions

App Router はデフォルトでは app ディレクトリ内の `page.tsx` というファイルのみページとみなされますが、他のファイル名もページとしてみなしたい場合に有効です。

例えば以下のように、環境変数と一緒に使うことで開発中のページは開発環境にだけ表示させ、本番環境ではアクセスできないようにすることが可能です。

```next.config.js
/** @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` 以外へのアクセスを防ぐことができます。

```next.config.js
/** @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` に書いたものがページとして表示されます。

![スクリーンショット 2024-09-01 19.32.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448688/5641e3de-f01c-33dc-87eb-b0a474a7b770.png)


このファイルを用意しない場合は Next.js デフォルトの 404 ページが出ます。

![スクリーンショット 2024-09-01 19.33.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448688/858de4ba-a05c-bd7d-4ca0-6d11de0f8ed5.png)


https://nextjs.org/docs/app/api-reference/file-conventions/not-found

ですがほとんどの場合、 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
```

```next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ["page.tsx", "page.dev.tsx"],
};

module.exports = nextConfig;
```

```not-found.page.dev.tsx
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 v14.2.5](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448688/b3e8325a-da90-3750-d06a-61dcdebb9239.png)
（1 枚目： Next.js `v14.2.5` ）

![Next.js v13.5.6](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448688/254266c2-1dca-776f-c80b-0ffea1708206.png)
（2 枚目: Next.js `v13.5.6` ）

Next.js のバージョンによりエラー時の文言が違いますが、エラーが起きるケースはどちらも App Router を使用していて、 pageExtensions 設定済み・独自の 404 ページを使用している場合です。

# バグの原因
私が確認した Next.js `v14.2.5` と `v13.5.6` でエラー時の該当ファイルが異なっていたため、それぞれ見ていきます。

## v14.2.5
このバージョンのエラーについては [issue の解説](https://github.com/vercel/next.js/issues/65447#issuecomment-2108105490) を引用しています。

```next.js/packages/next/src/server/app-render/app-render.tsx
// TODO: fix this typescript 
const clientReferenceManifest = renderOpts.clientReferenceManifest! 
```
ここで `clientReferenceManifest` に対して Non-null assertion を使用していますが、この変数を使用している箇所で undefined になり得るのでランタイムエラーになっていました。

```next.js/packages/next/src/server/app-render/app-render.tsx
const serverStream = ComponentMod.renderToReadableStream( 
   <ReactServerApp tree={tree} ctx={ctx} asNotFound={asNotFound} />, 
   clientReferenceManifest.clientModules,  // ココ
   { 
     onError: serverComponentsErrorHandler, 
     nonce, 
   } 
) 
```

### 補足

`clientReferenceManifest` が何者なのか見たところ、以下のようなオブジェクトでした。
```next.js/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts
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 行目部分](https://github.com/vercel/next.js/blob/0bf7f52db8b2c1e0b13d332de105fab645240e1e/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts#L349-L351)も修正しないと正しく動かなそうでした。

```next.js/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts
if (
    name === `app${UNDERSCORE_NOT_FOUND_ROUTE_ENTRY}` &&
    bundlePath === 'app/not-found'
) {
```
ここの `bundlePath === 'app/not-found'`が pageExtensions を考慮されていませんでした。
以下が `bundlePath` を出力してみた結果です。

```bash
# ======================================
# .
# └── 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 行目部分](https://github.com/vercel/next.js/blob/9051bc44d969a6e0ab65a955a2fc0af522a83911/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts#L423-L425) が原因です。

以下の if 文に入ると、 404 エラー用の設定ファイルを読み込んで適切な処理をしてくれます。

```next.js/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts
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 になりません。


![regexp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448688/a80aae31-c4b7-3ccf-9c8e-63574b522957.png)
(正規表現のイメージ)


いくつかのケースを出力してみましょう。
``` ts
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 つまでということになります。
```js
// こちらは有効
pageExtensions: ["page.tsx", "hoge.tsx", "ts"],

// ドットが2つ以上入っているとエラーになってしまう
pageExtensions: ["page.dev.tsx", "hoge.huga.tsx", "hoge.huga.piyo.tsx"],

```

# まとめ
Next.js のバージョンによりエラーの発生箇所は異なりますが、エラーになるケースはいずれも
- App Router を使用している
- pageExtensions を使用している
- 独自の 404 ページを表示している

で一致していることがわかりました。

:::note warn
私が調べたバージョンは `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 にする

のどれかで逃げるか、修正されるのを祈りましょう。

:::note info
バグ解説がメインだったので解決方法については列挙しただけでしたが、 1 つ目の「 pageExtensionsに ".ts" を追加し、 404 ページだけ .ts で運用する」について少し補足します。

こちらは [issue に workaround として書かれていたもの](https://github.com/vercel/next.js/issues/65447#issuecomment-2294801418) で、404 ページだけ `not-found.ts` として運用する方法です。

コメント主の azu さんが [サンプル](https://github.com/azu/next-typeerror-not-found-workaround/commit/10f57454ae69acac4014f61078d72e0871e86b79#diff-5fe67bf41bab0b0fe64301e3487e679b241ecc3406dd3e43e3f4da78820fc47a) を載せてくれているので、そちらを見るとわかりやすいです。
:::
　

今回紹介したバグの他に、App Router と Pages Router が共存した状態（移行途中）で起きるバグもあるので、Pages Router から App Router へ移行するのは個人的にまだちょっと怖いです。

https://oisham.hatenablog.com/entry/2024/04/04/105444

（参考: とろろこんぶろぐ - App Router 移行時に 0.01 % の確率で CSR 遷移が 404 エラーになる）



ですが React の最新機能を最大限活かすために、いつか App Router へ安心して移行できる日が来ることを願っています。