😎
【Next.js完全ガイド Vol.5】エラーハンドリング・SEO・実践パターン総まとめ
【Next.js完全ガイド Vol.5】エラーハンドリング・SEO・実践パターン総まとめ
はじめに
この記事は「Next.js完全ガイド」シリーズの第5回(最終回)です。
📚 シリーズ構成
- 第1回: Next.js 15最新情報 & アーキテクチャの基礎
- 第2回: レンダリング戦略の完全理解
- 第3回: データフェッチング戦略
- 第4回: キャッシング戦略
- 第5回: エラーハンドリング・メタデータ・実践パターン ← 今ここ(完結)
前回のおさらい
前回は、Next.jsの4層のキャッシング戦略を学びました:
- Request Memoization(リクエスト内での重複排除)
- Data Cache(永続的なデータキャッシュ)
- Full Route Cache(静的ルート全体のキャッシュ)
- Router Cache(クライアント側のナビゲーションキャッシュ)
この記事で学べること
- error.jsとloading.jsを使った宣言的なエラー・ローディング処理
- not-found.jsによる404ページのカスタマイズ
- メタデータAPIを使ったSEO最適化
- 構造化データ(JSON-LD)の実装
- robots.txtとsitemap.xmlの生成
- Next.jsの設計哲学とベストプラクティス
1. エラーハンドリングとローディング状態
error.jsとloading.jsの役割
Next.jsは、特殊なファイル名を使ってエラーとローディング状態を宣言的に処理します。
app/
├── layout.js
├── page.js
├── loading.js ← ローディングUI
├── error.js ← エラーUI
└── products/
├── page.js
├── loading.js ← このルート専用のローディング
└── error.js ← このルート専用のエラー処理
2. loading.js
基本的な使い方
// app/dashboard/loading.js
export default function Loading() {
return (
<div className="loading">
<div className="spinner"></div>
<p>読み込み中...</p>
</div>
);
}
動作:
ユーザーが /dashboard にアクセス
↓
loading.js が即座に表示
↓
page.js のデータフェッチ(バックグラウンド)
↓
データ取得完了
↓
page.js に切り替わる
内部的な仕組み(Suspense境界)
loading.jsは、内部的に自動でSuspenseでラップされます:
// Next.jsが自動的に行う処理
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
スケルトンUIの実装
// app/products/loading.js
export default function ProductsLoading() {
return (
<div className="products-grid">
{[...Array(12)].map((_, i) => (
<div key={i} className="skeleton-card">
<div className="skeleton-image" />
<div className="skeleton-title" />
<div className="skeleton-price" />
</div>
))}
</div>
);
}
CSS:
.skeleton-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
}
.skeleton-image,
.skeleton-title,
.skeleton-price {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
.skeleton-image {
height: 200px;
margin-bottom: 12px;
border-radius: 4px;
}
.skeleton-title {
height: 20px;
margin-bottom: 8px;
border-radius: 4px;
}
.skeleton-price {
height: 24px;
width: 80px;
border-radius: 4px;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
3. error.js
基本的な使い方
// app/products/error.js
'use client'; // error.jsは必ずClient Component
import { useEffect } from 'react';
export default function Error({
error,
reset,
}) {
useEffect(() => {
// エラーログを送信
console.error(error);
}, [error]);
return (
<div className="error-container">
<h2>問題が発生しました</h2>
<p>{error.message}</p>
<button onClick={reset}>
もう一度試す
</button>
</div>
);
}
動作:
page.jsでエラー発生
↓
最も近いerror.jsが自動的にキャッチ
↓
エラーUIを表示
↓
ユーザーが「もう一度試す」をクリック
↓
reset()関数が実行される
↓
page.jsが再レンダリング
エラーの種類別処理
// app/products/[id]/error.js
'use client';
import Link from 'next/link';
export default function ProductError({ error, reset }) {
// 404エラー
if (error.message.includes('Not Found')) {
return (
<div className="error-404">
<h1>404</h1>
<p>商品が見つかりません</p>
<Link href="/products">商品一覧に戻る</Link>
</div>
);
}
// ネットワークエラー
if (error.message.includes('Failed to fetch')) {
return (
<div className="error-network">
<h2>接続エラー</h2>
<p>インターネット接続を確認してください</p>
<button onClick={reset}>再試行</button>
</div>
);
}
// その他のエラー
return (
<div className="error-general">
<h2>エラーが発生しました</h2>
<p>{error.message}</p>
<button onClick={reset}>もう一度試す</button>
</div>
);
}
エラー境界の階層
app/
├── error.js ← グローバルエラーハンドラ
├── layout.js
├── page.js
└── products/
├── error.js ← /products 以下のエラーをキャッチ
├── page.js
└── [id]/
├── error.js ← /products/[id] のエラーをキャッチ
└── page.js
エラー伝播:
/products/123/page.js でエラー発生
↓
最も近い error.js を探す
↓
/products/[id]/error.js が見つかった
↓
ここでキャッチ(親には伝播しない)
もし /products/[id]/error.js がなければ
↓
/products/error.js を探す
↓
それもなければ
↓
/error.js(ルート)を探す
リトライ付きエラーハンドリング
// app/products/error.js
'use client';
import { useState } from 'react';
import Link from 'next/link';
export default function ProductsError({ error, reset }) {
const [retryCount, setRetryCount] = useState(0);
const maxRetries = 3;
const handleRetry = () => {
if (retryCount < maxRetries) {
setRetryCount(prev => prev + 1);
reset();
}
};
if (retryCount >= maxRetries) {
return (
<div className="error-max-retries">
<h2>問題が解決しません</h2>
<p>後でもう一度お試しいただくか、サポートにお問い合わせください。</p>
<Link href="/support">サポートに連絡</Link>
</div>
);
}
return (
<div className="error-retry">
<h2>データの読み込みに失敗しました</h2>
<p>試行回数: {retryCount}/{maxRetries}</p>
<button onClick={handleRetry}>
もう一度試す
</button>
</div>
);
}
4. not-found.js
基本的な使い方
// app/products/[id]/not-found.js
import Link from 'next/link';
export default function NotFound() {
return (
<div className="not-found">
<h1>404</h1>
<h2>商品が見つかりません</h2>
<p>お探しの商品は削除されたか、URLが間違っている可能性があります。</p>
<Link href="/products">
商品一覧に戻る
</Link>
</div>
);
}
ページでの使用
// app/products/[id]/page.js
import { notFound } from 'next/navigation';
export default async function ProductPage({ params }) {
const product = await fetch(`https://api.example.com/products/${params.id}`)
.then(r => {
if (!r.ok) return null;
return r.json();
});
if (!product) {
notFound(); // not-found.jsを表示
}
return <ProductDetails product={product} />;
}
5. global-error.js
layout.jsのエラーをキャッチするための特殊なエラーハンドラです。
// app/global-error.js
'use client';
export default function GlobalError({ error, reset }) {
return (
<html>
<body>
<div className="global-error">
<h2>致命的なエラーが発生しました</h2>
<p>{error.message}</p>
<button onClick={reset}>リロード</button>
</div>
</body>
</html>
);
}
注意点:
-
<html>と<body>タグを自分で記述する必要がある - layout.jsが壊れているため、通常のレイアウトは使用できない
6. ローディング状態の高度な制御
細かいSuspense境界
// app/dashboard/page.js
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div className="dashboard">
{/* 即座に表示 */}
<header>
<h1>ダッシュボード</h1>
</header>
{/* 各セクションを独立してストリーミング */}
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
async function Stats() {
const stats = await fetchStats(); // 300ms
return <StatsDisplay data={stats} />;
}
async function Chart() {
const data = await fetchChartData(); // 1000ms
return <ChartComponent data={data} />;
}
async function RecentActivity() {
const activity = await fetchActivity(); // 500ms
return <ActivityList items={activity} />;
}
ユーザー体験のタイムライン:
0ms: ヘッダー表示 + 3つのスケルトン表示
300ms: Stats表示(他はまだスケルトン)
500ms: RecentActivity表示
1000ms: Chart表示 → 全て完了!
7. メタデータとSEO最適化
メタデータAPIの基本
静的メタデータ
// app/about/page.js
export const metadata = {
title: '会社概要 | My Company',
description: '私たちの会社について詳しく紹介します',
keywords: ['会社概要', '企業情報', 'about'],
authors: [{ name: 'My Company' }],
openGraph: {
title: '会社概要',
description: '私たちの会社について',
images: ['/og-image.png'],
},
twitter: {
card: 'summary_large_image',
title: '会社概要',
description: '私たちの会社について',
images: ['/twitter-image.png'],
},
};
export default function AboutPage() {
return <div>会社概要...</div>;
}
動的メタデータ
// app/products/[id]/page.js
export async function generateMetadata({ params }) {
const product = await fetch(`https://api.example.com/products/${params.id}`)
.then(r => r.json());
return {
title: `${product.name} | My Shop`,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [product.image],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: product.name,
description: product.description,
images: [product.image],
},
};
}
export default async function ProductPage({ params }) {
const product = await fetch(`https://api.example.com/products/${params.id}`)
.then(r => r.json());
return <ProductDetails product={product} />;
}
タイトルテンプレート
// app/layout.js
export const metadata = {
title: {
template: '%s | My Shop', // %sがページタイトルに置き換わる
default: 'My Shop - 最高の商品をお届けします',
},
};
// app/products/page.js
export const metadata = {
title: '商品一覧', // → 「商品一覧 | My Shop」になる
};
// app/about/page.js
export const metadata = {
title: '会社概要', // → 「会社概要 | My Shop」になる
};
// app/page.js
// metadataを設定しない → 「My Shop - 最高の商品をお届けします」になる
8. 構造化データ(JSON-LD)
構造化データを使うと、Googleの検索結果にリッチスニペット(星評価、価格、在庫状況など)が表示されます。
// app/products/[id]/page.js
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.id);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
image: product.image,
description: product.description,
brand: {
'@type': 'Brand',
name: 'My Brand',
},
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'JPY',
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: product.rating,
reviewCount: product.reviewCount,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProductDetails product={product} />
</>
);
}
ブログ記事の構造化データ
// app/blog/[slug]/page.js
export default async function BlogPost({ params }) {
const post = await fetchPost(params.slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
image: post.image,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author.name,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
</>
);
}
9. robots.txtとsitemap.xml
robots.txt
// app/robots.js
export default function robots() {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/'],
},
sitemap: 'https://example.com/sitemap.xml',
};
}
生成されるrobots.txt:
User-Agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: https://example.com/sitemap.xml
sitemap.xml
// app/sitemap.js
export default async function sitemap() {
const products = await fetchAllProducts();
const productUrls = products.map(product => ({
url: `https://example.com/products/${product.id}`,
lastModified: product.updatedAt,
changeFrequency: 'daily',
priority: 0.8,
}));
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: 'https://example.com/about',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.5,
},
...productUrls,
];
}
10. Open Graph画像の動的生成
Next.jsは、ImageResponseを使ってOG画像を動的に生成できます。
// app/api/og/route.jsx
import { ImageResponse } from 'next/og';
export async function GET(request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') || 'デフォルトタイトル';
return new ImageResponse(
(
<div
style={{
display: 'flex',
fontSize: 60,
color: 'white',
background: 'linear-gradient(to bottom right, #1e3a8a, #3b82f6)',
width: '100%',
height: '100%',
padding: '50px 80px',
textAlign: 'left',
justifyContent: 'center',
alignItems: 'center',
}}
>
{title}
</div>
),
{
width: 1200,
height: 630,
}
);
}
// app/products/[id]/page.js
export async function generateMetadata({ params }) {
const product = await fetchProduct(params.id);
return {
title: product.name,
openGraph: {
images: [`/api/og?title=${encodeURIComponent(product.name)}`],
},
};
}
11. Next.jsの設計哲学
開発者体験(DX)とユーザー体験(UX)の両立
Next.jsは、以下の3つの柱で設計されています:
┌────────────────────────────────────────┐
│ 1. パフォーマンス最適化がデフォルト │
│ - 自動コード分割 │
│ - 画像最適化 │
│ - フォント最適化 │
│ - スクリプト最適化 │
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ 2. 柔軟なレンダリング戦略 │
│ - 静的レンダリング │
│ - 動的レンダリング │
│ - ストリーミング │
│ - すべてを組み合わせ可能 │
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ 3. 段階的な採用が可能 │
│ - 既存プロジェクトに追加可能 │
│ - 一部だけNext.jsを使える │
│ - 少しずつ移行できる │
└────────────────────────────────────────┘
12. 推奨される学習ロードマップ
フェーズ1: 基礎(1-2週間)
✅ App Routerの基本
✅ Server ComponentsとClient Componentsの違い
✅ 基本的なルーティング
✅ layoutとpageの使い方
フェーズ2: 実践(2-3週間)
✅ データフェッチング(fetch、Server Actions)
✅ 静的レンダリングと動的レンダリング
✅ loading.jsとerror.jsの使い方
✅ メタデータAPIの基本
フェーズ3: 最適化(2-3週間)
✅ キャッシング戦略
✅ ストリーミングとSuspense
✅ パフォーマンス最適化
✅ SEO対策
フェーズ4: 応用(継続的)
✅ ミドルウェア
✅ 国際化(i18n)
✅ 認証・認可
✅ リアルタイム機能
13. よくある質問(FAQ)
Q1: いつClient Componentsを使うべき?
Client Componentsを使う場合:
✅ イベントリスナー(onClick、onChangeなど)
✅ ステート(useState、useReducerなど)
✅ ライフサイクル(useEffectなど)
✅ ブラウザAPI(localStorage、windowなど)
✅ カスタムフック
それ以外はServer Componentsを使う
Q2: Server ActionsとAPI Routesの使い分けは?
Server Actions:
✅ フォーム送信
✅ 内部のデータ変更
✅ プログレッシブエンハンスメントが必要
API Routes:
✅ 外部クライアント向けAPI
✅ Webhook受信
✅ RESTful APIが必要
Q3: キャッシュをどこまで使うべき?
積極的にキャッシュ:
✅ ほとんど変わらないデータ(会社概要など)
✅ 頻繁にアクセスされるデータ
キャッシュしない:
✅ ユーザー固有のデータ
✅ リアルタイム性が重要なデータ
✅ センシティブな情報
Q4: Turbopackは本番環境で使える?
Next.js 15の時点:
✅ 開発環境: 安定版(next dev --turbo)
❌ 本番環境: まだ実験的
開発時のみ使用を推奨
14. シリーズ完結のまとめ
全5回で学んだこと:
第1回: 基礎
- ✅ Next.js 15の新機能
- ✅ Server ComponentsとClient Componentsのアーキテクチャ
- ✅ RSC Payloadの仕組み
- ✅ ミドルウェアの役割
第2回: レンダリング
- ✅ 静的レンダリング、動的レンダリング、ストリーミング
- ✅ コンポーネント単位でのレンダリング方式の決定
- ✅ 従来のSSR/CSRとの関係
第3回: データフェッチング
- ✅ 4つのデータフェッチングパターン
- ✅ Server Actionsの実践
- ✅ SWRを使った高度なクライアントサイドフェッチング
第4回: キャッシング
- ✅ 4つのキャッシュ層の仕組み
- ✅ Next.js 14と15の違い
- ✅ 実践的なキャッシング戦略
第5回: 応用(今回)
- ✅ エラーハンドリングとローディング状態
- ✅ メタデータAPIとSEO最適化
- ✅ 構造化データとサイトマップ生成
15. Next.jsで開発を始めるための次のステップ
1. 公式ドキュメントを読む
Next.js公式ドキュメント:
https://nextjs.org/docs
特に重要なセクション:
- App Router
- Routing
- Data Fetching
- Caching
2. サンプルプロジェクトを作る
# Next.jsプロジェクトを作成
npx create-next-app@latest my-app
# 開発サーバーを起動
cd my-app
npm run dev
3. 実践的なプロジェクトに挑戦
おすすめのプロジェクト:
✅ ブログシステム
✅ ECサイト
✅ ダッシュボード
✅ ポートフォリオサイト
4. コミュニティに参加
✅ Next.js公式Discord
✅ GitHub Discussions
✅ Qiita、Zennで情報発信
✅ X(Twitter)でハッシュタグ #nextjs をフォロー
参考リンク集
公式リソース
学習リソース
コミュニティ
最後に
この「Next.js完全ガイド」シリーズを最後までお読みいただき、ありがとうございました!
Next.jsは非常に強力なフレームワークですが、その分学ぶことも多いです。しかし、基礎から段階的に学んでいけば、必ず理解できます。
このシリーズが、皆さんのNext.js学習の一助となれば幸いです。
Happy coding! 🚀
シリーズ記事
- 第1回: Next.js 15最新情報 & アーキテクチャの基礎
- 第2回: レンダリング戦略の完全理解
- 第3回: データフェッチング戦略
- 第4回: キャッシング戦略
- 第5回: エラーハンドリング・メタデータ・実践パターン ← 今ここ(完結)
全5回、お疲れさまでした!
Discussion