できない.dev

Next.js App Router の not-found.tsx が呼ばれない

App Router の not-found.tsx は notFound() が throw されるか、ルートに該当パスが存在しない時のみレンダリングされる。
動的セグメントで notFound() を明示的に呼ぶか、dynamicParams = false を付ける必要がある。

公開: 更新:

実行例あり(2026-10-02 に実環境で検証)

要約

App Router の 404 は「ルートに対応するファイルが無い」場合と、「ページ内から notFound() が throw された」場合の 2 系統で発火する。
動的セグメントでデータが無い時に勝手に 404 にはならないので、ページ側で notFound() を明示的に呼ぶか、dynamicParams = false で静的生成範囲外を 404 にする必要がある。

実行例

記事が見つからないときに <h1>Not Found</h1> を描画して返すページは HTTP 200 のままで robots meta も付かなかったが、notFound() を呼ぶページは 404 になり noindex が付いた。generateStaticParams だけのルートは未知のスラッグでも 200 で描画され、dynamicParams = false を付けたルートでは 404 になって app/not-found.js の見出しが出ている。

$ cat "app/bad/[slug]/page.js"
import { getPost } from "../../posts";
 
export default async function Page({ params }) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return <h1>Not Found</h1>;
  return <h1>{post}</h1>;
}
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/bad/nope
200
$ curl -s http://localhost:3000/bad/nope | grep -o -e '<h1>[^<]*</h1>' -e '<meta name="robots"[^>]*>'
<h1>Not Found</h1>
$ cat "app/blog/[slug]/page.js"
import { notFound } from "next/navigation";
import { getPost } from "../../posts";
 
export default async function Page({ params }) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();
  return <h1>{post}</h1>;
}
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/blog/nope
404
$ curl -s http://localhost:3000/blog/nope | grep -o -e '<h1>[^<]*</h1>' -e '<meta name="robots"[^>]*>'
<meta name="robots" content="noindex"/>
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/blog/hello
200
$ curl -s http://localhost:3000/blog/hello | grep -o -e '<h1>[^<]*</h1>' -e '<meta name="robots"[^>]*>'
<h1>Hello, Next.js</h1>
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/docs/nope
200
$ curl -s http://localhost:3000/docs/nope | grep -o -e '<h1>[^<]*</h1>' -e '<meta name="robots"[^>]*>'
<h1>docs: nope</h1>
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/static/nope
404
$ curl -s http://localhost:3000/static/nope | grep -o -e '<h1>[^<]*</h1>' -e '<meta name="robots"[^>]*>'
<meta name="robots" content="noindex"/>
<h1>ページが見つかりません(app/not-found.js)</h1>

— 2026-10-02 時点の出力

検証環境

検証日
実行環境
node:20Debian GNU/Linux 12 (bookworm)
バージョン
  • Node.js 20.20.2
  • npm 10.8.2
  • Python 3.11.2
  • Git 2.39.5

この記事の「実行例」は、上記の環境で実際にコマンドを実行して得られた出力をそのまま掲載しています。 再現手順はリポジトリの検証スクリプトとして管理し、定期的に再実行して出力を更新しています。

よくある原因

  1. app/blog/[slug]/page.tsx でデータ取得失敗時に return <div>Not Found</div> を描画している(HTTP 200 で返る)
  2. not-found.tsx がルート(app/not-found.tsx)にしか無く、ネストの 404 が反応していないように見える
  3. middleware.ts で全リクエストを書き換えており、Next.js のルーティングまで届かない
  4. generateStaticParams で全パターンを返しているのに dynamicParams を指定しておらず、未知のスラッグが動的描画になっている
  5. Pages Router の pages/404.tsx の感覚で App Router を使っており、ファイル名を 404.tsx にしている

解決策

1. notFound() を明示的に呼ぶ

import { notFound } from 'next/navigation'
 
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()
  return <article>{post.title}</article>
}

Next.js 15 以降の params は Promise なので、await してから使う(16 で同期アクセスは廃止された)。notFound() は throw する関数なので、呼んだ後の処理は実行されない。
最も近い not-found.tsx に到達するまで境界を遡る(公式リファレンス(新しいタブで開く))。

2. セグメントごとに not-found.tsx を置く

app/
  not-found.tsx          # ルート全体の 404
  blog/
    not-found.tsx        # /blog 配下の 404
    [slug]/
      page.tsx

階層を遡って最も近い not-found.tsx が使われる。
Pages Router の 404.tsx は App Router では使わない(file-conventions: not-found.js(新しいタブで開く))。

3. 静的生成範囲外を 404 にする

export const dynamicParams = false
 
export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((p) => ({ slug: p.slug }))
}

dynamicParams = false を付けると、generateStaticParams が返さなかったスラッグへのアクセスは自動的に 404 になる。

4. middleware を疑う

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

middleware の matcher が広すぎて Next.js のルーティングが阻害されると、404 そのものが発火しない。matcher で静的アセットや API を除外する。

この記事は役立ちましたか?