Next.jsとSupabaseでオンライ学習プラットフォーム（LMS）を構築する | エピソード4: コース詳細ページとSEO最適化

こんにちは！前回のエピソードでは、Supabase Databaseに`courses`テーブルを設計し、コース一覧ページ（CLP）を構築しました。今回は、コース詳細ページ（Course Detail Page - CDP）を構築し、SEOを最適化します。Next.jsの`getStaticPaths`と`getStaticProps`を活用して静的ページを生成し、Next.js Imageで画像を最適化、Open GraphやTwitter Cardsのメタタグを追加します。これで、コースページが高速で検索エンジンに優しいものになります！

## このエピソードのゴール

- `getStaticPaths`と`getStaticProps`でコース詳細ページを静的に生成。
- Next.js Imageを使ってコース画像を最適化。
- Open GraphとTwitter CardsのメタタグでSEOを強化。
- Tailwind CSSでレスポンシブなCDPをデザイン。

## 必要なもの

- 前回のプロジェクト（`next-lms`）がセットアップ済み。
- Supabaseプロジェクト（`courses`テーブルにデータあり）。
- 基本的なTypeScript、React、Next.jsの知識。

---

## ステップ1: コース詳細データの取得

Supabaseからコースの詳細データを取得する関数を追加します。

1. **Supabaseクエリ関数の更新**：
   `src/lib/supabase.ts`にコース詳細取得関数を追加：

```ts
import { createClient } from '@supabase/supabase-js';
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient(supabaseUrl, supabaseKey);
export const supabaseServer = () =>
  createServerComponentClient({ cookies }, { supabaseUrl, supabaseKey });

export interface Course {
  id: string;
  title: string;
  description: string;
  price: number;
  instructor_id: string;
  created_at: string;
  updated_at: string;
  image_url?: string; // コース画像（後でSupabase Storageに追加）
}

export async function getCourses(page: number = 1, limit: number = 10) {
  const start = (page - 1) * limit;
  const end = start + limit - 1;

  const { data, error } = await supabase
    .from('courses')
    .select('*')
    .range(start, end)
    .order('created_at', { ascending: false });

  if (error) throw error;
  return data as Course[];
}

export async function getCoursesCount() {
  const { count, error } = await supabase
    .from('courses')
    .select('*', { count: 'exact', head: true });

  if (error) throw error;
  return count || 0;
}

export async function getCourseById(id: string) {
  const { data, error } = await supabase
    .from('courses')
    .select('*')
    .eq('id', id)
    .single();

  if (error) throw error;
  return data as Course;
}

export async function getAllCourseIds() {
  const { data, error } = await supabase
    .from('courses')
    .select('id');

  if (error) throw error;
  return data.map((item) => ({ id: item.id }));
}
```

このコードは：
- `getCourseById`: 指定したIDのコース詳細を取得。
- `getAllCourseIds`: すべてのコースIDを取得（`getStaticPaths`で使用）。

2. **Supabaseテーブルの更新**：
   Supabaseダッシュボードで`courses`テーブルに`image_url`カラム（型: `text`）を追加し、テストデータに画像URLを仮に設定（例: `https://via.placeholder.com/600x400`）。

```sql
ALTER TABLE courses ADD COLUMN image_url text;
UPDATE courses SET image_url = 'https://via.placeholder.com/600x400' WHERE image_url IS NULL;
```

---

## ステップ2: コース詳細ページ（CDP）の構築

Next.jsの動的ルーティングを使ってCDPを構築します。`src/app/courses/[id]/page.tsx`を作成：

```tsx
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getCourseById, Course } from '@/lib/supabase';
import { Metadata } from 'next';

export async function generateStaticParams() {
  const courses = await getAllCourseIds();
  return courses.map((course) => ({
    id: course.id,
  }));
}

export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
  const course = await getCourseById(params.id);
  if (!course) return {};

  return {
    title: `${course.title} | Next.js LMS`,
    description: course.description,
    openGraph: {
      title: course.title,
      description: course.description,
      images: [{ url: course.image_url || 'https://via.placeholder.com/600x400' }],
    },
    twitter: {
      card: 'summary_large_image',
      title: course.title,
      description: course.description,
      images: [course.image_url || 'https://via.placeholder.com/600x400'],
    },
  };
}

export default async function CourseDetailPage({ params }: { params: { id: string } }) {
  const course = await getCourseById(params.id);

  if (!course) {
    notFound();
  }

  return (
    <main className="container mx-auto p-4">
      <div className="bg-white rounded-lg shadow-lg p-6">
        <Image
          src={course.image_url || 'https://via.placeholder.com/600x400'}
          alt={course.title}
          width={600}
          height={400}
          className="w-full h-64 object-cover rounded-lg mb-6"
          priority
        />
        <h1 className="text-3xl font-bold mb-4">{course.title}</h1>
        <p className="text-gray-600 mb-4">{course.description}</p>
        <p className="text-2xl font-semibold text-primary mb-4">{course.price.toLocaleString()} 円</p>
        <a
          href="/checkout"
          className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90"
        >
          このコースを購入
        </a>
      </div>
    </main>
  );
}
```

このコードは：
- `generateStaticParams`: すべてのコースIDを事前生成。
- `generateMetadata`: 動的なSEOメタタグ（タイトル、説明、OG/Twitter）を生成。
- Next.js Imageで画像を最適化（`priority`でLCPを改善）。
- Tailwind CSSでレスポンシブなデザインを適用。

---

## ステップ3: ナビゲーションの強化

コース詳細ページへのリンクが正しく機能するよう、ナビゲーションを微調整します。`src/app/courses/page.tsx`を確認し、コースカードのリンクが`/courses/[id]`形式であることを確認：

```tsx
<Link href={`/courses/${course.id}`} key={course.id}>
  <div className="border rounded-lg p-4 hover:shadow-lg transition">
    <Image
      src={course.image_url || `/placeholder-course-${course.id}.jpg`}
      alt={course.title}
      width={300}
      height={200}
      className="w-full h-40 object-cover rounded"
      loading="lazy"
    />
    <h2 className="text-xl font-semibold mt-2">{course.title}</h2>
    <p className="text-gray-600 line-clamp-2">{course.description}</p>
    <p className="text-primary font-bold mt-2">{course.price.toLocaleString()} 円</p>
  </div>
</Link>
```

**注意**: 画像URLを`course.image_url`に更新し、プレースホルダー画像を条件付きで使用。

---

## ステップ4: 動作確認

1. Supabaseダッシュボードで`courses`テーブルの`image_url`カラムとテストデータが正しいことを確認。
2. 開発サーバーを起動：

```bash
npm run dev
```

3. `http://localhost:3000/courses`にアクセスし、コースカードをクリックして`/courses/[id]`に遷移：
   - コースタイトル、説明、価格、画像が正しく表示される。
   - 画像が高速に読み込まれ、レスポンシブに表示される。
   - ブラウザの開発者ツールで`<head>`内のメタタグ（OG/Twitter）が正しいことを確認。
4. ソーシャルメディア（例: Twitter）にURLを貼り付け、プレビューが正しく表示されるか確認。
5. 存在しないコースID（例: `/courses/invalid-id`）にアクセスし、404ページが表示されることを確認。

エラーがあれば、Supabaseのテーブル設定や`getCourseById`のクエリを確認してください。

---

## まとめと次のステップ

このエピソードでは、コース詳細ページ（CDP）を構築し、SEOを最適化しました。`getStaticPaths`と`getStaticProps`で静的ページを生成し、Next.js Imageで画像を最適化、Open GraphやTwitter Cardsでソーシャル共有を強化しました。これで、コースページが高速で検索エンジンに優しいものになりました！

次回のエピソードでは、Supabase Storageを使ってビデオレッスンを管理・配信します。`lessons`テーブルの設計と、React Playerを使ったビデオ再生機能を実装しますので、引き続きお楽しみに！

---

この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです！次回のエピソードもお楽しみに！