😎

【Next.js完全ガイド Vol.5】エラーハンドリング・SEO・実践パターン総まとめ

に公開

【Next.js完全ガイド Vol.5】エラーハンドリング・SEO・実践パターン総まとめ

はじめに

この記事は「Next.js完全ガイド」シリーズの第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! 🚀


シリーズ記事

全5回、お疲れさまでした!

Discussion