T-CREATOR

【入門】Next.js App Router 完全ガイド:Pages Router からの移行ステップ

【入門】Next.js App Router 完全ガイド:Pages Router からの移行ステップ

【入門】Next.js App Router 完全ガイド:Pages Router からの移行ステップ

Next.js 13で導入されたApp Routerは、フロントエンド開発において革新的な変化をもたらしました。これまでのPages Routerから大幅に改良され、より直感的で柔軟な開発体験を提供します。

多くの開発者がPages Routerに慣れ親しんでいるかもしれませんが、App Routerへの移行は将来性とパフォーマンスの向上を考えると避けて通れない道でしょう。この記事では、Pages RouterからApp Routerへの移行を段階的に進めるための完全ガイドをお届けします。

初心者の方でも安心して取り組めるよう、具体的なコード例と共に詳しく解説いたします。移行作業は一見複雑に見えますが、正しい手順を踏めば確実に成功できますので、ぜひ最後までお読みください。

Next.js App Routerとは

App Routerは、Next.js 13で安定版として導入された新しいルーティングシステムです。従来のPages Routerを置き換える形で設計されており、React 18の新機能を最大限活用できるよう最適化されています。

以下の図は、App Routerの基本的な構造を示しています。

mermaidflowchart TD
  app[app/ディレクトリ] --> layout[layout.tsx]
  app --> page[page.tsx]
  app --> loading[loading.tsx]
  app --> error[error.tsx]
  app --> dashboard[dashboard/]
  dashboard --> dashLayout[layout.tsx]
  dashboard --> dashPage[page.tsx]
  dashboard --> users[users/]
  users --> userPage[page.tsx]

App Routerの核となる特徴を詳しく見てみましょう。

Server Componentsによる最適化

App Routerの最大の特徴は、React Server Componentsがデフォルトで有効になっていることです。これにより、サーバーサイドでのレンダリングが効率化され、クライアントへ送信されるJavaScriptバンドルサイズが大幅に削減されます。

typescript// app/components/ServerComponent.tsx
async function ServerComponent() {
  // サーバーサイドで実行されるデータフェッチ
  const data = await fetch('https://api.example.com/data');
  const posts = await data.json();

  return (
    <div>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
        </article>
      ))}
    </div>
  );
}

改良されたファイルベースルーティング

App Routerでは、ルーティングの仕組みがより直感的になりました。appディレクトリ内の構造がそのままURLパスに対応し、特別なファイル名を使って様々な機能を実装できます。

ファイル名機能説明
page.tsxページコンポーネントそのルートのメインコンテンツ
layout.tsxレイアウトコンポーネント子ルートで共有されるUI
loading.tsxローディングUIデータフェッチ中に表示される
error.tsxエラーUIエラー発生時に表示される
not-found.tsx404ページページが見つからない時の表示

強力なレイアウトシステム

レイアウトコンポーネントは、App Routerの中でも特に強力な機能です。親ディレクトリのレイアウトが自動的に子ルートに適用され、ネストしたレイアウト構造を簡単に実現できます。

typescript// app/layout.tsx(ルートレイアウト)
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <header>
          <nav>グローバルナビゲーション</nav>
        </header>
        <main>{children}</main>
        <footer>フッター</footer>
      </body>
    </html>
  );
}
typescript// app/dashboard/layout.tsx(ダッシュボード専用レイアウト)
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="dashboard-container">
      <aside>
        <nav>ダッシュボードサイドバー</nav>
      </aside>
      <section>{children}</section>
    </div>
  );
}

Pages RouterからApp Routerへの移行が必要な理由

Pages RouterからApp Routerへの移行は、単なる新機能の採用以上の意味を持ちます。以下の図は、両者の主要な違いを表しています。

mermaidgraph TB
  subgraph "Pages Router"
    pages[pages/ディレクトリ]
    pages --> index1[index.js]
    pages --> about1[about.js]
    pages --> api1[api/]
    pages --> _app1[_app.js]
    pages --> _document1[_document.js]
  end
  
  subgraph "App Router"
    app[app/ディレクトリ]
    app --> layout2[layout.tsx]
    app --> page2[page.tsx]
    app --> about2[about/page.tsx]
    app --> api2[api/route.ts]
    app --> loading2[loading.tsx]
    app --> error2[error.tsx]
  end

パフォーマンスの大幅向上

App RouterではServer Componentsがデフォルトで有効になっており、クライアントサイドで実行されるJavaScriptの量が劇的に減少します。これにより、初期ページロード時間が最大30%短縮されるケースも報告されています。

typescript// Pages Router(クライアントサイドレンダリング)
import { useEffect, useState } from 'react';

export default function PostsPage() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/posts')
      .then(res => res.json())
      .then(data => {
        setPosts(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>読み込み中...</div>;

  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  );
}
typescript// App Router(サーバーサイドレンダリング)
async function PostsPage() {
  // サーバーサイドで直接データを取得
  const posts = await fetch('https://api.example.com/posts').then(res => res.json());

  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}
    </div>
  );
}

開発体験の向上

App Routerでは、ファイル構造がより直感的になり、コードの可読性と保守性が大幅に改善されます。特に大規模なプロジェクトでは、この差が顕著に現れるでしょう。

将来性とサポート

Next.js公式チームは、App Routerを今後の開発の中心に据えています。Pages Routerも当面はサポートされますが、新機能の多くはApp Routerでのみ提供される予定です。

移行前の準備

移行作業を始める前に、現在のプロジェクトの状況を正確に把握することが重要です。適切な準備により、移行プロセスがスムーズに進行し、予期しない問題を回避できます。

現行プロジェクトの確認

まずは、現在のプロジェクト構造を詳細に把握しましょう。以下のチェックリストを参考に、プロジェクトの状況を整理してください。

確認項目チェックポイント重要度
Next.jsのバージョン13.0.0以上であることを確認
ファイル構造pagesディレクトリの内容を把握
データフェッチ方法getServerSideProps、getStaticPropsの使用箇所
カスタムApp/Document_app.js、_document.jsのカスタマイズ内容
APIルートpages/api配下のファイル数と複雑さ

プロジェクト構造の確認コマンド

現在のプロジェクト構造を把握するため、以下のコマンドを実行してください。

bash# プロジェクトの詳細情報を確認
yarn list --depth=0

# ファイル構造の確認
tree pages/ -I 'node_modules'
bash# Next.jsのバージョン確認
yarn list next

# 依存関係のサイズ確認
yarn list --pattern="react|next"

依存関係の確認

App Routerへの移行では、一部のライブラリが互換性の問題を起こす可能性があります。事前に依存関係をチェックし、必要に応じてアップデートしましょう。

重要な依存関係のチェック

json{
  "dependencies": {
    "next": "^13.4.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}

以下のライブラリは、App Routerとの互換性を特に注意深く確認する必要があります。

typescript// package.jsonの依存関係確認スクリプト
const checkCompatibility = {
  "next-auth": "App Router対応版への更新が必要",
  "styled-components": "Server Componentsでの制限事項あり",
  "emotion": "設定の変更が必要",
  "tailwindcss": "基本的に互換性あり",
  "material-ui": "v5以降で対応済み"
};

バックアップの作成

移行作業を始める前に、必ずプロジェクトの完全バックアップを作成してください。

bash# Gitでの現在の状態を保存
git add .
git commit -m "Pages Router移行前のバックアップ"

# 新しいブランチを作成
git checkout -b feature/app-router-migration

段階的移行手順

App Routerへの移行は一度に全てを変更するのではなく、段階的に進めることをお勧めします。この方法により、問題が発生した場合の原因特定が容易になり、安全に移行を完了できます。

ルーティング構造の移行

最初のステップとして、基本的なルーティング構造をApp Router形式に変換していきます。Pages RouterとApp Routerは併用できるため、少しずつ移行することが可能です。

以下の図は、移行プロセスの全体像を示しています。

mermaidflowchart LR
  subgraph "移行前"
    pages1[pages/index.js] --> app1[app/page.tsx]
    pages2[pages/about.js] --> app2[app/about/page.tsx]
    pages3["pages/blog/[slug].js"] --> app3["app/blog/[slug]/page.tsx"]
  end
  
  subgraph "移行プロセス"
    step1[ステップ1: 基本ページ]
    step2[ステップ2: 動的ルート]
    step3[ステップ3: レイアウト]
    step4[ステップ4: データフェッチ]
    step1 --> step2
    step2 --> step3
    step3 --> step4
  end

ステップ1: 基本的なページの移行

最も単純なページから移行を開始しましょう。まずは、ルートページ(index.js)から始めます。

typescript// pages/index.js(移行前)
import Head from 'next/head';

export default function Home() {
  return (
    <>
      <Head>
        <title>ホームページ</title>
        <meta name="description" content="サイトの説明" />
      </Head>
      <main>
        <h1>ようこそ</h1>
        <p>これはホームページです。</p>
      </main>
    </>
  );
}
typescript// app/page.tsx(移行後)
import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'ホームページ',
  description: 'サイトの説明',
};

export default function Home() {
  return (
    <main>
      <h1>ようこそ</h1>
      <p>これはホームページです。</p>
    </main>
  );
}

ステップ2: 動的ルートの移行

動的ルートの移行では、ファイル名の形式が若干変更されます。ブラケット記法は同じですが、ディレクトリ構造が異なります。

typescript// pages/blog/[slug].js(移行前)
import { useRouter } from 'next/router';

export default function BlogPost() {
  const router = useRouter();
  const { slug } = router.query;

  return (
    <article>
      <h1>記事: {slug}</h1>
      <p>記事の内容がここに表示されます。</p>
    </article>
  );
}
typescript// app/blog/[slug]/page.tsx(移行後)
interface PageProps {
  params: {
    slug: string;
  };
}

export default function BlogPost({ params }: PageProps) {
  return (
    <article>
      <h1>記事: {params.slug}</h1>
      <p>記事の内容がここに表示されます。</p>
    </article>
  );
}

レイアウトの移行

レイアウトの移行は、App Routerの大きな利点の一つです。従来の_app.js_document.jsの機能を、より柔軟で直感的なレイアウトシステムに置き換えます。

ルートレイアウトの作成

まず、アプリケーション全体のレイアウトを定義します。

typescript// pages/_app.js(移行前)
import '../styles/globals.css';

export default function MyApp({ Component, pageProps }) {
  return (
    <>
      <header>
        <nav>グローバルナビゲーション</nav>
      </header>
      <Component {...pageProps} />
      <footer>フッター</footer>
    </>
  );
}
typescript// app/layout.tsx(移行後)
import './globals.css';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <header>
          <nav>グローバルナビゲーション</nav>
        </header>
        {children}
        <footer>フッター</footer>
      </body>
    </html>
  );
}

ネストしたレイアウトの実装

App Routerでは、ディレクトリごとに異なるレイアウトを定義できます。これにより、セクション固有のデザインやナビゲーションを簡単に実装できます。

typescript// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="dashboard-container">
      <aside className="sidebar">
        <nav>
          <a href="/dashboard">ダッシュボード</a>
          <a href="/dashboard/analytics">分析</a>
          <a href="/dashboard/settings">設定</a>
        </nav>
      </aside>
      <main className="content">
        {children}
      </main>
    </div>
  );
}

データフェッチの移行

データフェッチの移行は、App Routerへの移行において最も重要な部分の一つです。Pages RouterのgetServerSidePropsgetStaticPropsから、新しいデータフェッチパターンに移行します。

getServerSidePropsの移行

typescript// pages/posts.js(移行前)
export async function getServerSideProps() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

  return {
    props: {
      posts,
    },
  };
}

export default function Posts({ posts }) {
  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}
typescript// app/posts/page.tsx(移行後)
async function getPosts() {
  const res = await fetch('https://api.example.com/posts');
  
  if (!res.ok) {
    throw new Error('データの取得に失敗しました');
  }
  
  return res.json();
}

export default async function Posts() {
  const posts = await getPosts();

  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}

getStaticPropsの移行

静的生成を使用しているページの移行方法を説明します。

typescript// pages/about.js(移行前)
export async function getStaticProps() {
  const content = await fetch('https://api.example.com/about').then(res => res.text());

  return {
    props: {
      content,
    },
    revalidate: 3600, // 1時間ごとに再生成
  };
}

export default function About({ content }) {
  return (
    <div>
      <h1>私たちについて</h1>
      <div dangerouslySetInnerHTML={{ __html: content }} />
    </div>
  );
}
typescript// app/about/page.tsx(移行後)
async function getContent() {
  const res = await fetch('https://api.example.com/about', {
    next: { revalidate: 3600 } // 1時間ごとに再生成
  });
  
  return res.text();
}

export default async function About() {
  const content = await getContent();

  return (
    <div>
      <h1>私たちについて</h1>
      <div dangerouslySetInnerHTML={{ __html: content }} />
    </div>
  );
}

API Routesの移行

API Routesの移行は比較的シンプルですが、新しいRequest/Responseハンドリングパターンに慣れる必要があります。

基本的なAPIルートの移行

typescript// pages/api/hello.js(移行前)
export default function handler(req, res) {
  if (req.method === 'GET') {
    res.status(200).json({ message: 'Hello World' });
  } else {
    res.setHeader('Allow', ['GET']);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}
typescript// app/api/hello/route.ts(移行後)
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  return NextResponse.json({ message: 'Hello World' });
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  
  return NextResponse.json({ 
    message: 'データを受信しました',
    data: body 
  });
}

より複雑なAPIルートの移行

typescript// pages/api/posts/[id].js(移行前)
export default async function handler(req, res) {
  const { id } = req.query;
  
  if (req.method === 'GET') {
    try {
      const post = await getPostById(id);
      res.status(200).json(post);
    } catch (error) {
      res.status(404).json({ error: 'Post not found' });
    }
  }
}
typescript// app/api/posts/[id]/route.ts(移行後)
import { NextRequest, NextResponse } from 'next/server';

interface RouteParams {
  params: {
    id: string;
  };
}

export async function GET(
  request: NextRequest,
  { params }: RouteParams
) {
  try {
    const post = await getPostById(params.id);
    return NextResponse.json(post);
  } catch (error) {
    return NextResponse.json(
      { error: 'Post not found' },
      { status: 404 }
    );
  }
}

移行時の注意点とトラブルシューティング

App Routerへの移行過程では、様々な課題に遭遇する可能性があります。事前に一般的な問題とその解決策を理解しておくことで、スムーズな移行を実現できるでしょう。

よくある移行エラーと解決法

エラー1: Server ComponentでのuseStateの使用

App RouterではデフォルトでServer Componentsが使用されるため、useStateuseEffectなどのクライアントサイドフックは使用できません。

typescript// ❌ エラーが発生するコード
export default function MyComponent() {
  const [count, setCount] = useState(0); // Error: useState is not defined
  
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

このエラーの解決方法は、コンポーネントをClient Componentに変更することです。

typescript// ✅ 修正後のコード
'use client';

import { useState } from 'react';

export default function MyComponent() {
  const [count, setCount] = useState(0);
  
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

エラー2: 環境変数のアクセスエラー

Server ComponentsとClient Componentsでは、環境変数へのアクセス方法が異なります。

typescript// ❌ Client Componentで全ての環境変数にアクセス
'use client';

export default function ClientComponent() {
  // プライベートな環境変数はクライアントサイドで取得できない
  const apiSecret = process.env.API_SECRET; // undefined
  
  return <div>{apiSecret}</div>;
}
typescript// ✅ 適切な環境変数の使用
// Server Component
export default async function ServerComponent() {
  // サーバーサイドでプライベート環境変数を使用
  const apiSecret = process.env.API_SECRET;
  const data = await fetchWithSecret(apiSecret);
  
  return <ClientComponent data={data} />;
}

// Client Component
'use client';

export default function ClientComponent({ data }) {
  // パブリック環境変数のみ使用可能
  const publicUrl = process.env.NEXT_PUBLIC_API_URL;
  
  return <div>{data.title}</div>;
}

パフォーマンスの最適化ポイント

Dynamic Importsの活用

大きなコンポーネントやライブラリは、必要な時にのみ読み込むよう設定しましょう。

typescript// 重いコンポーネントの遅延読み込み
import { lazy, Suspense } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));

export default function Dashboard() {
  return (
    <div>
      <h1>ダッシュボード</h1>
      <Suspense fallback={<div>チャートを読み込み中...</div>}>
        <HeavyChart />
      </Suspense>
    </div>
  );
}

適切なキャッシュ戦略

App Routerでは、データフェッチのキャッシュ戦略を細かく制御できます。

typescript// 静的データ(ビルド時に取得)
async function getStaticData() {
  const res = await fetch('https://api.example.com/static-data', {
    cache: 'force-cache'
  });
  return res.json();
}

// 動的データ(リクエスト毎に取得)
async function getDynamicData() {
  const res = await fetch('https://api.example.com/dynamic-data', {
    cache: 'no-store'
  });
  return res.json();
}

// 時間ベースの再検証
async function getRevalidatedData() {
  const res = await fetch('https://api.example.com/data', {
    next: { revalidate: 60 } // 60秒毎に再検証
  });
  return res.json();
}

デバッグのコツ

Server/Client Componentの識別

どのコンポーネントがサーバーまたはクライアントで実行されているかを確認する方法を示します。

typescript// デバッグ用のログコンポーネント
export function DebugComponent({ name }: { name: string }) {
  console.log(`${name} - Server Component executed`);
  
  return null;
}

// 使用例
export default function MyPage() {
  return (
    <div>
      <DebugComponent name="MyPage" />
      <h1>ページコンテンツ</h1>
    </div>
  );
}
typescript// Client Component用のデバッグ
'use client';

import { useEffect } from 'react';

export function ClientDebugComponent({ name }: { name: string }) {
  useEffect(() => {
    console.log(`${name} - Client Component mounted`);
  }, [name]);
  
  return null;
}

実践例:シンプルなブログサイトの移行

実際の移行プロセスを理解するため、シンプルなブログサイトをPages RouterからApp Routerに移行する例を詳しく見てみましょう。この例では、段階的な移行アプローチを採用し、各ステップでの変更点を明確に示します。

移行前のプロジェクト構造

まず、移行前のプロジェクト構造を確認しましょう。

cssblog-project/
├── pages/
│   ├── _app.js
│   ├── _document.js
│   ├── index.js
│   ├── about.js
│   ├── blog/
│   │   ├── index.js
│   │   └── [slug].js
│   └── api/
│       └── posts.js
├── components/
│   ├── Layout.js
│   ├── Header.js
│   └── Footer.js
├── styles/
│   └── globals.css
└── public/
    └── images/

ステップ1: App Routerの初期設定

最初に、appディレクトリを作成し、基本的なレイアウトを設定します。

typescript// app/layout.tsx
import '../styles/globals.css';

export const metadata = {
  title: '私のブログ',
  description: 'テクノロジーについて書いているブログです',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <div className="min-h-screen bg-gray-50">
          <header className="bg-white shadow-sm">
            <nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
              <div className="flex justify-between h-16">
                <div className="flex items-center">
                  <a href="/" className="text-xl font-bold text-gray-900">
                    私のブログ
                  </a>
                </div>
                <div className="flex items-center space-x-8">
                  <a href="/" className="text-gray-700 hover:text-gray-900">
                    ホーム
                  </a>
                  <a href="/blog" className="text-gray-700 hover:text-gray-900">
                    ブログ
                  </a>
                  <a href="/about" className="text-gray-700 hover:text-gray-900">
                    について
                  </a>
                </div>
              </div>
            </nav>
          </header>
          <main>{children}</main>
          <footer className="bg-gray-800 text-white py-8">
            <div className="max-w-7xl mx-auto px-4 text-center">
              <p>&copy; 2024 私のブログ. All rights reserved.</p>
            </div>
          </footer>
        </div>
      </body>
    </html>
  );
}

ステップ2: ホームページの移行

typescript// pages/index.js(移行前)
import Head from 'next/head';
import Link from 'next/link';

export async function getStaticProps() {
  const res = await fetch('http://localhost:3000/api/posts');
  const posts = await res.json();

  return {
    props: {
      posts: posts.slice(0, 3), // 最新3件のみ
    },
    revalidate: 3600,
  };
}

export default function Home({ posts }) {
  return (
    <>
      <Head>
        <title>ホーム - 私のブログ</title>
      </Head>
      <div className="max-w-7xl mx-auto py-12 px-4">
        <h1 className="text-4xl font-bold text-center mb-12">
          最新の記事
        </h1>
        <div className="grid md:grid-cols-3 gap-8">
          {posts.map(post => (
            <article key={post.id} className="bg-white rounded-lg shadow-md overflow-hidden">
              <img src={post.image} alt={post.title} className="w-full h-48 object-cover" />
              <div className="p-6">
                <h2 className="text-xl font-semibold mb-2">{post.title}</h2>
                <p className="text-gray-600 mb-4">{post.excerpt}</p>
                <Link href={`/blog/${post.slug}`}>
                  <a className="text-blue-600 hover:text-blue-800">続きを読む</a>
                </Link>
              </div>
            </article>
          ))}
        </div>
      </div>
    </>
  );
}
typescript// app/page.tsx(移行後)
import Link from 'next/link';

async function getLatestPosts() {
  // Next.js 13+では、fetch がキャッシュされる
  const res = await fetch('http://localhost:3000/api/posts', {
    next: { revalidate: 3600 }
  });
  
  if (!res.ok) {
    throw new Error('記事の取得に失敗しました');
  }
  
  const posts = await res.json();
  return posts.slice(0, 3);
}

export default async function Home() {
  const posts = await getLatestPosts();

  return (
    <div className="max-w-7xl mx-auto py-12 px-4">
      <h1 className="text-4xl font-bold text-center mb-12">
        最新の記事
      </h1>
      <div className="grid md:grid-cols-3 gap-8">
        {posts.map(post => (
          <article key={post.id} className="bg-white rounded-lg shadow-md overflow-hidden">
            <img src={post.image} alt={post.title} className="w-full h-48 object-cover" />
            <div className="p-6">
              <h2 className="text-xl font-semibold mb-2">{post.title}</h2>
              <p className="text-gray-600 mb-4">{post.excerpt}</p>
              <Link href={`/blog/${post.slug}`} className="text-blue-600 hover:text-blue-800">
                続きを読む
              </Link>
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}

ステップ3: ブログ一覧ページの移行

typescript// app/blog/page.tsx
import Link from 'next/link';

async function getAllPosts() {
  const res = await fetch('http://localhost:3000/api/posts', {
    next: { revalidate: 1800 } // 30分毎に再検証
  });
  
  if (!res.ok) {
    throw new Error('記事の取得に失敗しました');
  }
  
  return res.json();
}

export const metadata = {
  title: 'ブログ一覧 - 私のブログ',
  description: '技術ブログの記事一覧ページです',
};

export default async function BlogList() {
  const posts = await getAllPosts();

  return (
    <div className="max-w-4xl mx-auto py-12 px-4">
      <h1 className="text-3xl font-bold mb-8">ブログ記事一覧</h1>
      <div className="space-y-8">
        {posts.map(post => (
          <article key={post.id} className="border-b border-gray-200 pb-8">
            <div className="flex flex-col md:flex-row gap-6">
              <img 
                src={post.image} 
                alt={post.title}
                className="w-full md:w-64 h-48 object-cover rounded-lg"
              />
              <div className="flex-1">
                <h2 className="text-2xl font-semibold mb-2">
                  <Link 
                    href={`/blog/${post.slug}`}
                    className="text-gray-900 hover:text-blue-600"
                  >
                    {post.title}
                  </Link>
                </h2>
                <p className="text-gray-600 mb-4">{post.excerpt}</p>
                <div className="flex items-center text-sm text-gray-500">
                  <time>{new Date(post.publishedAt).toLocaleDateString('ja-JP')}</time>
                  <span className="mx-2"></span>
                  <span>{post.readTime}分で読める</span>
                </div>
              </div>
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}

ステップ4: 動的ルート(記事詳細)の移行

typescript// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

async function getPost(slug: string) {
  const res = await fetch(`http://localhost:3000/api/posts/${slug}`, {
    next: { revalidate: 3600 }
  });
  
  if (!res.ok) {
    return null;
  }
  
  return res.json();
}

// 動的にメタデータを生成
export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  
  if (!post) {
    return {
      title: '記事が見つかりません',
    };
  }
  
  return {
    title: `${post.title} - 私のブログ`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.image],
    },
  };
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  
  if (!post) {
    notFound();
  }

  return (
    <article className="max-w-4xl mx-auto py-12 px-4">
      <header className="mb-8">
        <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
        <div className="flex items-center text-gray-600 mb-6">
          <time>{new Date(post.publishedAt).toLocaleDateString('ja-JP')}</time>
          <span className="mx-2"></span>
          <span>{post.readTime}分で読める</span>
          <span className="mx-2"></span>
          <span>{post.author}</span>
        </div>
        <img 
          src={post.image} 
          alt={post.title}
          className="w-full h-64 object-cover rounded-lg"
        />
      </header>
      
      <div 
        className="prose prose-lg max-w-none"
        dangerouslySetInnerHTML={{ __html: post.content }}
      />
    </article>
  );
}

ステップ5: 404ページとローディングページの追加

typescript// app/blog/[slug]/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div className="max-w-4xl mx-auto py-12 px-4 text-center">
      <h1 className="text-4xl font-bold mb-4">記事が見つかりません</h1>
      <p className="text-gray-600 mb-8">
        お探しの記事は存在しないか、削除された可能性があります。
      </p>
      <Link 
        href="/blog"
        className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700"
      >
        ブログ一覧に戻る
      </Link>
    </div>
  );
}
typescript// app/blog/[slug]/loading.tsx
export default function Loading() {
  return (
    <div className="max-w-4xl mx-auto py-12 px-4">
      <div className="animate-pulse">
        <div className="h-8 bg-gray-300 rounded mb-4"></div>
        <div className="h-4 bg-gray-300 rounded mb-6 w-1/3"></div>
        <div className="h-64 bg-gray-300 rounded mb-8"></div>
        <div className="space-y-3">
          <div className="h-4 bg-gray-300 rounded"></div>
          <div className="h-4 bg-gray-300 rounded"></div>
          <div className="h-4 bg-gray-300 rounded w-3/4"></div>
        </div>
      </div>
    </div>
  );
}

移行完了後の確認

移行が完了したら、以下の点を確認してください:

確認項目チェックポイント
ページ表示全てのページが正常に表示される
ナビゲーションリンクが適切に動作する
SEOメタデータが正しく設定されている
パフォーマンス読み込み速度が改善されている
エラーハンドリング404ページが適切に表示される

まとめ

Next.js App Routerへの移行は、初見では複雑に感じられるかもしれませんが、段階的なアプローチを採用することで確実に成功できます。本記事でご紹介した手順に従って進めることで、パフォーマンスの向上と開発体験の改善を実現できるでしょう。

移行の主要なメリット

App Routerへの移行により、以下のような具体的なメリットを享受できます:

  • パフォーマンス向上: Server Componentsによりクライアントサイドのバンドルサイズが平均30%削減
  • 開発効率の改善: より直感的なファイル構造とレイアウトシステム
  • SEO最適化: 改良されたメタデータAPIによる検索エンジン最適化
  • 将来性: Next.jsの最新機能を活用できる基盤の構築

移行時の重要なポイント

移行を成功させるためには、以下の点を念頭に置いて作業を進めることが重要です:

  1. 段階的なアプローチ: 一度に全てを変更せず、ページ単位で徐々に移行
  2. 十分なテスト: 各ステップでの動作確認を怠らない
  3. バックアップの活用: 問題が発生した場合の回復手段を確保
  4. パフォーマンス測定: 移行前後でのパフォーマンス比較

今後の学習へのステップ

App Routerの基本的な移行が完了したら、さらなる最適化に挑戦してみてください。Parallel Routes、Intercepting Routes、Streaming SSRなどの高度な機能を活用することで、より洗練されたWebアプリケーションを構築できます。

Next.jsコミュニティは非常に活発で、常に新しいベストプラクティスや最適化手法が共有されています。継続的な学習により、最新の開発手法を身につけ、ユーザーにとって価値のあるWebサービスを提供していきましょう。

関連リンク