Next.js notFoundの実装パターン:業務で使える404エラーハンドリング徹底解説
1. notFound()とは?簡易的な解説
Next.js 13以降で導入されたnotFound()は、アプリケーション内で404エラーページを表示するための組み込み関数です。従来のHTTPステータスコードやメタデータの手動設定とは異なり、宣言的で直感的なエラーハンドリングを実現します。
基本的な動作としては、notFound()を実行するとNext.jsの特別な404ページ(デフォルトまたはカスタム)へ自動的にリダイレクトされます。重要なのは、これが単なるページ遷移ではなく、HTTPステータスコード404を正しく返すという点です。
// 最もシンプルな例
import { notFound } from 'next/navigation';
export default function Page() {
const user = null; // ユーザーが見つからない場合
if (!user) {
notFound(); // 404ページを表示
}
return <div>User Profile</div>;
}
2. 業務でのユースケース
実際の開発現場では、様々なシーンでnotFound()が活躍します。
ユースケース1:動的ルートでのデータ検証
ブログ記事や商品詳細ページなど、URLパラメータから指定されたリソースが存在しない場合、404を返す必要があります。データベースクエリの結果に基づいて判定することが一般的です。
ユースケース2:ユーザー権限チェック
ログイン中のユーザーが特定のページにアクセス権限を持たない場合、見つかりませんという表現で404を返す方が、セキュリティ的に情報を隠蔽できます。
ユースケース3:A/Bテストやフィーチャーフラグ
特定のユーザーグループには新機能を見せず、他のグループには404を返すといった条件分岐が必要な場合があります。
ユースケース4:廃止されたコンテンツの処理
旧バージョンのAPIエンドポイントやレガシーページへのアクセスを適切に404で処理します。
3. 実装コード:業務レベルの実例
3-1. データベースクエリ結果に基づく404処理
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getBlogPost } from '@/lib/db';
import { BlogPost } from '@/types/blog';
interface BlogPageProps {
params: {
slug: string;
};
}
export async function generateStaticParams() {
// ビルド時に存在するスラッグを事前生成
const posts = await getBlogPost();
return posts.map((post: BlogPost) => ({
slug: post.slug,
}));
}
export async function generateMetadata({ params }: BlogPageProps) {
const post = await getBlogPost(params.slug);
if (!post) {
return {
title: 'ページが見つかりません',
};
}
return {
title: post.title,
description: post.excerpt,
};
}
export default async function BlogPage({ params }: BlogPageProps) {
const post = await getBlogPost(params.slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
3-2. API層での検証とエラーハンドリング
// app/api/users/[id]/route.ts
import { notFound } from 'next/navigation';
import { getUserById, deleteUser } from '@/lib/user-service';
import { verifyAuth } from '@/lib/auth';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getUserById(params.id);
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json(user);
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const authToken = request.headers.get('authorization');
const auth = await verifyAuth(authToken);
if (!auth) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const user = await getUserById(params.id);
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
await deleteUser(params.id);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
3-3. Server Componentでの権限チェック
// app/admin/dashboard/page.tsx
import { notFound } from 'next/navigation';
import { getSession } from '@/lib/auth';
import { AdminDashboard } from '@/components/admin-dashboard';
export default async function AdminPage() {
const session = await getSession();
// ログインしていない、または管理者権限がない場合
if (!session || !session.user.isAdmin) {
notFound();
}
return <AdminDashboard />;
}
3-4. カスタム404ページの実装
// app/not-found.tsx
import Link from 'next/link';
import { NotFoundIllustration } from '@/components/illustrations';
export default function NotFound() {
return (
<div className=\"flex flex-col items-center justify-center min-h-screen\">
<NotFoundIllustration />
<h1 className=\"text-4xl font-bold mt-8\">404 - ページが見つかりません</h1>
<p className=\"text-gray-600 mt-4\">
申し訳ありません。お探しのページは存在しないか、削除されています。
</p>
<div className=\"mt-8 space-x-4\">
<Link
href=\"/\"
className=\"px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700\"
>
ホームへ戻る
</Link>
<Link
href=\"/blog\"
className=\"px-6 py-2 border border-blue-600 text-blue-600 rounded-lg hover:bg-blue-50\"
>
ブログを見る
</Link>
</div>
</div>
);
}
4. よくある応用パターン
パターン1:複数の検証条件を組み合わせる
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
import { getProduct } from '@/lib/products';
import { isProductPublished } from '@/lib/validation';
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id);
// 複数の条件でnotFoundを実行
if (!product || !isProductPublished(product)) {
notFound();
}
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span className=\"text-2xl font-bold\">¥{product.price}</span>
</div>
);
}
パターン2:フィーチャーフラグとの組み合わせ
// app/new-feature/page.tsx
import { notFound } from 'next/navigation';
import { isFeatureEnabled } from '@/lib/feature-flags';
import { getSession } from '@/lib/auth';
export default async function NewFeaturePage() {
const session = await getSession();
// ユーザーに対して新機能が有効化されていない場合
const isEnabled = await isFeatureEnabled('new-feature', session?.user?.id);
if (!isEnabled) {
notFound();
}
return (
<div>
<h1>新機能へようこそ</h1>
<p>このページはベータテスト中です</p>
</div>
);
}
パターン3:キャッシュと組み合わせた最適化
// app/articles/[id]/page.tsx
import { notFound } from 'next/navigation';
import { getArticle, getCachedArticle } from '@/lib/articles';
import { revalidateTag } from 'next/cache';
export const revalidate = 3600; // 1時間ごとに再検証
export default async function ArticlePage({
params,
}: {
params: { id: string };
}) {
// まずキャッシュから取得
let article = getCachedArticle(params.id);
// キャッシュがない場合はDBから取得
if (!article) {
article = await getArticle(params.id);
if (!article) {
notFound();
}
// キャッシュを再検証
revalidateTag(`article-${params.id}`);
}
return (
<article>
<h1>{article.title}</h1>
<div className=\"prose\">{article.body}</div>
</article>
);
}
パターン4:ロケーション別の条件分岐
// app/regional/[region]/page.tsx
import { notFound } from 'next/navigation';
import { isRegionSupported, getRegionalContent } from '@/lib/regional';
const SUPPORTED_REGIONS = ['jp', 'us', 'eu', 'sg'];
export default async function RegionalPage({
params,
}: {
params: { region: string };
}) {
// サポート対象外の地域の場合
if (!SUPPORTED_REGIONS.includes(params.region)) {
notFound();
}
const content = await getRegionalContent(params.region);
if (!content) {
notFound();
}
return (
<div>
<h1>{content.title}</h1>
<p>{content.description}</p>
</div>
);
}
5. 注意点と落とし穴
注意点1:notFound()はServer ComponentでのみServer Componentで使用
notFound()はServer Componentで使用する関数です。Client Componentで使用したい場合は、別のエラーハンドリング方式(例えばコンポーネントの条件分岐やリダイレクト)を検討する必要があります。
// ❌ 間違い - Client Componentで使用
'use client';
import { notFound } from 'next/navigation';
export default function ClientComponent() {
// notFound()はClient Componentでは動作しません
return <div></div>;
}
// ✅ 正しい - Server Componentで使用
import { notFound } from 'next/navigation';
export default async function ServerComponent() {
const data = await fetchData();
if (!data) {
notFound();
}
return <div></div>;
}
注意点2:SEOへの影響を考慮
HTTP 404ステータスコードは検索エンジンのクローラーにページが存在しないことを伝えます。これは意図した動作ですが、本来存在すべきページを誤って404にしないよう注意が必要です。
注意点3:generateStaticParamsとの連携
静的生成(Static Generation)を使う場合、generateStaticParams()で返すパスのみがビルド時に生成されます。その他のパスへのアクセスは自動的に404になるため、定期的な再検証やオンデマンド静的生成を検討しましょう。
// app/items/[id]/page.tsx
export const dynamicParams = true; // 事前生成されていないパスも許可
export async function generateStaticParams() {
// 人気のあるアイテムのみ事前生成
const popularItems = await getPopularItems();
return popularItems.map((item: Item) => ({
id: item.id,
}));
}
export default async function ItemPage({ params }: { params: { id: string } }) {
const item = await getItem(params.id);
if (!item) {
notFound();
}
return <div>{item.name}</div>;
}
注意点4:キャッシュレイアウトの問題
親レイアウトでデータ取得している場合、子ページでnotFound()を呼ぶと親のキャッシュが無駄になる可能性があります。可能な限り早い段階でデータの存在確認をしましょう。
注意点5:ユーザー体験の配慮
404ページが表示されたとき、ユーザーが次にどこへ進むべきかわからないことがあります。カスタム404ページには適切なナビゲーションリンクやサイト検索機能を組み込みましょう。
6. 実装時のベストプラクティス
エラーロギングの実装
// lib/logging.ts
export async function logNotFound(
path: string,
reason: string,
context?: Record<string, unknown>
) {
const timestamp = new Date().toISOString();
// ログサービス(例:Sentry、LogRocket等)に送信
await fetch('/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'not_found',
path,
reason,
context,
timestamp,
}),
});
}
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
import { getProduct } from '@/lib/products';
import { logNotFound } from '@/lib/logging';
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id);
if (!product) {
await logNotFound(
`/products/${params.id}`,
'Product not found in database',
{ productId: params.id }
);
notFound();
}
return <div>{product.name}</div>;
}
段階的なデータ検証
// app/users/[username]/settings/page.tsx
import { notFound } from 'next/navigation';
import { getUserByUsername } from '@/lib/users';
import { getSession } from '@/lib/auth';
export default async function UserSettingsPage({
params,
}: {
params: { username: string };
}) {
// ステップ1:ユーザーの存在確認
const user = await getUserByUsername(params.username);
if (!user) {
notFound();
}
// ステップ2:権限の確認
const session = await getSession();
if (!session || session.user.id !== user.id) {
notFound();
}
// ステップ3:アカウントステータスの確認
if (user.status === 'suspended') {
notFound();
}
return (
<div>
<h1>{user.name}のアカウント設定</h1>
{/* 設定フォーム */}
</div>
);
}
まとめ
Next.jsのnotFound()関数は、シンプルながら強力なエラーハンドリングメカニズムです。業務レベルのアプリケーション開発では、以下の点を心に留めておくことが重要です。
- 正確性:データベースクエリ結果に基づいて適切に404を返す
- セキュリティ:権限チェックと組み合わせて情報を保護する
- パフォーマンス:キャッシュ戦略と連携させて効率的に動作させる
- ユーザー体験:カスタム404ページで適切なナビゲーションを提供する
- 可視性:404が発生した理由をログに記録して監視する
これらを実践することで、検索エンジン最適化(SEO)にも優れた、堅牢で保守しやすいNext.jsアプリケーションを構築できます。実装する際は、プロジェクトの要件に合わせて、紹介したパターンを参考にカスタマイズしてください。

