<CodeLearn/>
Next.js レッスン6

Next.js総合演習

これまで学んだ知識を活かして、シンプルなブログサイトを構築しよう

演習の概要

この演習では、Next.jsの主要機能を組み合わせてシンプルなブログサイトを 構築します。以下の機能を実装していきます。

  • トップページ(記事一覧)
  • 記事詳細ページ(動的ルート)
  • 共通レイアウト(ヘッダー、フッター)
  • 記事データのAPI(Route Handlers)
  • ローディングUI
# 完成時のフォルダ構造
src/app/
├── layout.tsx              # ルートレイアウト
├── page.tsx                # トップページ(記事一覧)
├── globals.css
├── blog/
│   ├── page.tsx            # ブログ一覧ページ
│   ├── loading.tsx         # ローディングUI
│   └── [slug]/
│       └── page.tsx        # 記事詳細ページ
├── api/
│   └── posts/
│       ├── route.ts        # GET: 全記事取得 / POST: 記事作成
│       └── [slug]/
│           └── route.ts    # GET: 個別記事取得
└── lib/
    └── posts.ts            # 記事データ管理

Step 1: 記事データの定義

まず、ブログ記事のデータを管理するモジュールを作成します。 実際のアプリではデータベースを使いますが、ここでは配列でシンプルに管理します。

// src/app/lib/posts.ts
export type Post = {
  slug: string;
  title: string;
  date: string;
  excerpt: string;
  content: string;
  tags: string[];
};

// ブログ記事データ
export const posts: Post[] = [
  {
    slug: "getting-started-with-nextjs",
    title: "Next.jsをはじめよう",
    date: "2025-01-15",
    excerpt: "Next.jsの基本的なセットアップと最初のページを作成する方法を解説します。",
    content: `
Next.jsは、Reactベースのフルスタックフレームワークです。

## なぜNext.jsを使うのか?

1. ファイルベースルーティングで設定が簡単
2. SSR/SSGで高速な初期表示
3. API Routesでバックエンドも構築可能

\`\`\`
npx create-next-app@latest my-blog
cd my-blog
npm run dev
\`\`\`

これだけで開発サーバーが起動します!
    `,
    tags: ["Next.js", "React", "入門"],
  },
  {
    slug: "understanding-server-components",
    title: "Server Componentsを理解する",
    date: "2025-01-20",
    excerpt: "React Server Componentsの仕組みと、Next.jsでの活用方法を学びます。",
    content: `
Server Componentsは、サーバー側で実行されるReactコンポーネントです。

## メリット

- バンドルサイズが小さくなる
- データベースに直接アクセスできる
- APIキーなどの秘密情報を安全に扱える

## 使い方

デフォルトでServer Componentです。Client Componentにするには
ファイルの先頭に "use client" を追記します。
    `,
    tags: ["React", "Server Components"],
  },
  {
    slug: "styling-with-tailwind",
    title: "Tailwind CSSでスタイリング",
    date: "2025-01-25",
    excerpt: "Next.jsプロジェクトでTailwind CSSを使った効率的なスタイリング手法。",
    content: `
Tailwind CSSは、ユーティリティファーストのCSSフレームワークです。

## セットアップ

create-next-appでTailwind CSSを選択すれば、自動的にセットアップされます。

## 基本的な使い方

\`\`\`jsx
<div className="bg-gray-900 p-4 rounded-lg">
  <h1 className="text-2xl font-bold text-white">
    タイトル
  </h1>
</div>
\`\`\`
    `,
    tags: ["CSS", "Tailwind"],
  },
];

// ヘルパー関数
export function getAllPosts(): Post[] {
  return posts.sort(
    (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
  );
}

export function getPostBySlug(slug: string): Post | undefined {
  return posts.find((post) => post.slug === slug);
}

Step 2: ルートレイアウトの作成

全ページに共通するヘッダーとフッターを含むレイアウトを作成します。metadataオブジェクトで SEO用のメタデータも設定します。

// src/app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";

export const metadata: Metadata = {
  title: {
    default: "My Blog",
    template: "%s | My Blog", // 子ページのtitleが埋め込まれる
  },
  description: "Next.jsで作ったブログサイト",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className="bg-gray-950 text-gray-100 min-h-screen flex flex-col">
        {/* ヘッダー */}
        <header className="border-b border-gray-800">
          <nav className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
            <Link href="/" className="text-xl font-bold text-purple-400">
              My Blog
            </Link>
            <div className="flex gap-6">
              <Link href="/" className="text-gray-400 hover:text-white">
                ホーム
              </Link>
              <Link href="/blog" className="text-gray-400 hover:text-white">
                記事一覧
              </Link>
            </div>
          </nav>
        </header>

        {/* メインコンテンツ */}
        <main className="flex-1 max-w-4xl mx-auto px-4 py-8 w-full">
          {children}
        </main>

        {/* フッター */}
        <footer className="border-t border-gray-800 py-6 text-center text-gray-500 text-sm">
          <p>&copy; 2025 My Blog. Built with Next.js</p>
        </footer>
      </body>
    </html>
  );
}

Step 3: APIルートの作成

記事データを取得するためのAPIエンドポイントを作成します。 全記事一覧と個別記事取得の2つのエンドポイントを用意します。

// src/app/api/posts/route.ts
import { NextResponse } from "next/server";
import { getAllPosts } from "@/app/lib/posts";

// GET /api/posts — 全記事を取得
export async function GET() {
  const posts = getAllPosts();

  // 一覧ではcontentを除外(データ量を減らす)
  const summaries = posts.map(({ content, ...rest }) => rest);

  return NextResponse.json(summaries);
}

// src/app/api/posts/[slug]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getPostBySlug } from "@/app/lib/posts";

// GET /api/posts/:slug — 個別記事を取得
export async function GET(
  request: NextRequest,
  { params }: { params: { slug: string } }
) {
  const post = getPostBySlug(params.slug);

  if (!post) {
    return NextResponse.json(
      { error: "記事が見つかりません" },
      { status: 404 }
    );
  }

  return NextResponse.json(post);
}

Step 4: ブログページの作成

記事一覧ページと記事詳細ページを作成します。 Server Componentでデータを直接取得し、ローディングUIも追加します。

// src/app/blog/page.tsx — 記事一覧
import Link from "next/link";
import { getAllPosts } from "@/app/lib/posts";
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "記事一覧",
};

export default function BlogPage() {
  const posts = getAllPosts();

  return (
    <div>
      <h1 className="text-3xl font-bold mb-8">記事一覧</h1>

      <div className="space-y-6">
        {posts.map((post) => (
          <article
            key={post.slug}
            className="p-6 rounded-xl bg-gray-900 border border-gray-800
                       hover:border-purple-500/50 transition-colors"
          >
            <Link href={`/blog/${post.slug}`}>
              <div className="flex gap-2 mb-2">
                {post.tags.map((tag) => (
                  <span
                    key={tag}
                    className="text-xs px-2 py-1 rounded-full
                               bg-purple-500/20 text-purple-400"
                  >
                    {tag}
                  </span>
                ))}
              </div>
              <h2 className="text-xl font-bold text-white mb-2">
                {post.title}
              </h2>
              <p className="text-gray-400 text-sm mb-2">{post.excerpt}</p>
              <time className="text-gray-500 text-xs">{post.date}</time>
            </Link>
          </article>
        ))}
      </div>
    </div>
  );
}
// src/app/blog/loading.tsx — ローディングUI
export default function Loading() {
  return (
    <div className="space-y-6">
      <div className="h-10 bg-gray-800 rounded w-1/3 animate-pulse"></div>
      {[1, 2, 3].map((i) => (
        <div
          key={i}
          className="p-6 rounded-xl bg-gray-900 border border-gray-800 animate-pulse"
        >
          <div className="h-4 bg-gray-800 rounded w-1/4 mb-3"></div>
          <div className="h-6 bg-gray-800 rounded w-3/4 mb-3"></div>
          <div className="h-4 bg-gray-800 rounded w-full mb-2"></div>
          <div className="h-3 bg-gray-800 rounded w-1/5"></div>
        </div>
      ))}
    </div>
  );
}
// src/app/blog/[slug]/page.tsx — 記事詳細
import { getPostBySlug, getAllPosts } from "@/app/lib/posts";
import { notFound } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";

// 動的メタデータ
export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = getPostBySlug(params.slug);
  if (!post) return { title: "記事が見つかりません" };

  return {
    title: post.title,
    description: post.excerpt,
  };
}

// ビルド時に静的ページを生成(SSG)
export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default function BlogPostPage({
  params,
}: {
  params: { slug: string };
}) {
  const post = getPostBySlug(params.slug);

  // 記事が見つからない場合は404
  if (!post) {
    notFound();
  }

  return (
    <article>
      <div className="mb-8">
        <Link
          href="/blog"
          className="text-purple-400 hover:text-purple-300 text-sm"
        >
          ← 記事一覧に戻る
        </Link>
      </div>

      <header className="mb-8">
        <div className="flex gap-2 mb-3">
          {post.tags.map((tag) => (
            <span
              key={tag}
              className="text-xs px-2 py-1 rounded-full
                         bg-purple-500/20 text-purple-400"
            >
              {tag}
            </span>
          ))}
        </div>
        <h1 className="text-3xl font-bold text-white mb-2">
          {post.title}
        </h1>
        <time className="text-gray-500">{post.date}</time>
      </header>

      <div className="prose prose-invert max-w-none">
        {/* 実際のアプリではMarkdownパーサーを使う */}
        <div className="text-gray-300 leading-relaxed whitespace-pre-line">
          {post.content}
        </div>
      </div>
    </article>
  );
}

Step 5: ビルドとデプロイ

アプリが完成したら、ビルドして本番環境にデプロイしましょう。 Next.jsの開発元であるVercelを使えば、GitHubリポジトリと連携するだけで自動デプロイが可能です。

# 1. ビルド(本番用に最適化)
npm run build

# ビルド結果の例:
# Route (app)                    Size     First Load JS
# ┌ ○ /                          5.2 kB   89.5 kB
# ├ ○ /blog                      3.1 kB   87.4 kB
# ├ ● /blog/[slug]               1.8 kB   86.1 kB
# │   ├ /blog/getting-started-with-nextjs
# │   ├ /blog/understanding-server-components
# │   └ /blog/styling-with-tailwind
# └ ○ /api/posts                 0 B      0 B
#
# ○ 静的(SSG)  ● SSG(generateStaticParams)

# 2. ローカルで本番ビルドを確認
npm run start

# 3. Vercelにデプロイ
# 方法A: Vercel CLIを使う
npx vercel

# 方法B: GitHubリポジトリと連携(推奨)
# 1. GitHubにリポジトリをpush
# 2. vercel.com でリポジトリをインポート
# 3. 自動的にビルド・デプロイされる
# 4. pushするたびに自動で再デプロイ

まとめ

  • Next.jsでブログサイトを構築する一連の流れを実践した
  • ファイルベースルーティングでページを作成し、動的ルートで記事詳細ページを実装した
  • ルートレイアウトで共通のヘッダー・フッターを適用した
  • Route Handlersで記事データを提供するAPIを構築した
  • generateStaticParamsでビルド時に静的ページを生成した
  • generateMetadataで動的にSEOメタデータを設定した
  • loading.tsxでローディングUIを追加し、ユーザー体験を向上させた
  • Vercelで簡単にデプロイできることを確認した