Next.jsとSupabaseでオンライ学習プラットフォーム（LMS）を構築する | エピソード3: コース管理と一覧ページの構築

こんにちは！前回のエピソードでは、Supabase Authを使ってユーザー認証システムを構築し、メールとGoogle OAuthでの登録・ログイン機能を実装しました。今回は、Supabase Databaseを活用してコース管理機能を作成します。`courses`テーブルを設計し、コース一覧ページ（Course Listing Page - CLP）を構築します。Next.jsの`getStaticProps`とTailwind CSSを使って、レスポンシブで高速なUIを設計し、ページネーションも追加します！

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

- Supabaseに`courses`テーブルを設計。
- `getStaticProps`を使ってコースデータを取得。
- Tailwind CSSでコース一覧ページ（CLP）を構築。
- ページネーションまたは無限スクロールを実装。

## 必要なもの

- 前回のプロジェクト（`next-lms`）がセットアップ済み。
- Supabaseプロジェクト（データベース設定済み）。
- 基本的なTypeScript、React、Next.jsの知識。

---

## ステップ1: Supabaseに`courses`テーブルの設計

Supabaseのデータベースにコース情報を保存するテーブルを作成します。

1. **Supabaseダッシュボードでテーブル作成**：
   - [Supabaseダッシュボード](https://supabase.com/)にログイン。
   - 「Table Editor」→「New Table」で`courses`テーブルを作成。
   - 以下のカラムを定義：

| カラム名        | タイプ       | 説明                     |
|-----------------|--------------|--------------------------|
| id              | uuid         | 主キー（自動生成）       |
| title           | text         | コースタイトル           |
| description     | text         | コース説明               |
| price           | numeric      | 価格（円）               |
| instructor_id   | uuid         | 講師のユーザーID（外部キー） |
| created_at      | timestamptz  | 作成日時（自動生成）     |
| updated_at      | timestamptz  | 更新日時（自動生成）     |

   - 「Enable Row Level Security (RLS)」を有効化し、以下のようなポリシーを追加：
     - **Select Policy**: 誰でもコースを閲覧可能（`true`）。
     - **Insert/Update Policy**: 認証済みユーザーのみ（`auth.uid() = instructor_id`）。

2. **テストデータの挿入**：
   Supabaseの「Table Editor」またはSQL Editorでテストデータを追加：

```sql
INSERT INTO courses (title, description, price, instructor_id)
VALUES
  ('React入門', 'Reactを使ったWeb開発を学びます。', 5000, 'あなたのユーザーID'),
  ('TypeScriptマスター', 'TypeScriptの基礎から応用まで。', 8000, 'あなたのユーザーID');
```

**注意**: `instructor_id`はSupabase AuthのユーザーID（ダッシュボードの「Authentication」→「Users」で確認）を使用してください。

---

## ステップ2: コースデータの取得

Next.jsの`getStaticProps`を使って、コースデータを取得します。

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;
}

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;
}
```

このコードは：
- `getCourses`: 指定されたページと制限に基づいてコースを取得。
- `getCoursesCount`: 総コース数を返し、ページネーションに使用。

---

## ステップ3: コース一覧ページ（CLP）の構築

コース一覧ページを構築し、Tailwind CSSでレスポンシブなデザインを適用します。`src/app/courses/page.tsx`を作成：

```tsx
import Link from 'next/link';
import Image from 'next/image';
import { getCourses, getCoursesCount, Course } from '@/lib/supabase';

interface CoursesPageProps {
  searchParams: { page?: string };
}

export default async function CoursesPage({ searchParams }: CoursesPageProps) {
  const page = parseInt(searchParams.page || '1', 10);
  const limit = 6; // 1ページあたり6コース
  const courses = await getCourses(page, limit);
  const totalCourses = await getCoursesCount();
  const totalPages = Math.ceil(totalCourses / limit);

  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">コース一覧</h1>
      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
        {courses.map((course) => (
          <Link href={`/courses/${course.id}`} key={course.id}>
            <div className="border rounded-lg p-4 hover:shadow-lg transition">
              <Image
                src={`/placeholder-course-${course.id}.jpg`} // 仮の画像（後でSupabase Storageに置き換え）
                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>
        ))}
      </div>
      {/* ページネーション */}
      <div className="flex justify-center gap-4 mt-8">
        {page > 1 && (
          <Link href={`/courses?page=${page - 1}`} className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300">
            前へ
          </Link>
        )}
        {page < totalPages && (
          <Link href={`/courses?page=${page + 1}`} className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300">
            次へ
          </Link>
        )}
      </div>
    </main>
  );
}
```

このコードは：
- `getStaticProps`の代わりにサーバーコンポーネントでデータ取得（App Router）。
- コースをカード形式で表示し、Tailwind CSSでレスポンシブデザインを適用。
- ページネーションを実装（前へ/次へボタン）。
- 仮の画像を使用（次のエピソードでSupabase Storageに置き換え）。

---

## ステップ4: ナビゲーションの更新

ヘッダーに「コース一覧」リンクを追加します。`src/app/layout.tsx`を更新：

```tsx
import '../styles/globals.css';
import Link from 'next/link';
import { supabaseServer } from '@/lib/supabase';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const supabase = supabaseServer();
  const { data: { user } } = await supabase.auth.getUser();

  return (
    <html lang="ja">
      <body className="bg-gray-100">
        <header className="bg-primary text-white p-4">
          <div className="container mx-auto flex justify-between items-center">
            <h1 className="text-2xl font-bold">
              <Link href="/">Next.js LMS</Link>
            </h1>
            <nav className="flex gap-4">
              <Link href="/courses" className="hover:underline">コース</Link>
              {user ? (
                <>
                  <Link href="/dashboard" className="hover:underline">ダッシュボード</Link>
                  <form
                    action={async () => {
                      'use server';
                      await supabase.auth.signOut();
                    }}
                    className="inline"
                  >
                    <button className="hover:underline">ログアウト</button>
                  </form>
                </>
              ) : (
                <>
                  <Link href="/login" className="hover:underline">ログイン</Link>
                  <Link href="/register" className="hover:underline">登録</Link>
                </>
              )}
            </nav>
          </div>
        </header>
        {children}
      </body>
    </html>
  );
}
```

---

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

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

```bash
npm run dev
```

3. `http://localhost:3000/courses`にアクセスし、以下の点を確認：
   - コース一覧がカード形式で表示される。
   - タイトル、説明、価格が正しく表示される。
   - ページネーションの「前へ」「次へ」ボタンが機能する（データが多い場合）。
   - モバイルデバイスでもレスポンシブに表示される。
4. ヘッダーの「コース」リンクをクリックし、`/courses`に遷移することを確認。

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

---

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

このエピソードでは、Supabase Databaseに`courses`テーブルを設計し、コース一覧ページ（CLP）を構築しました。`getStaticProps`の代わりにサーバーコンポーネントを活用し、ページネーションを追加することで、ユーザーがコースを簡単に閲覧できるようにしました。

次回のエピソードでは、コース詳細ページ（CDP）を構築し、SEO最適化を行います。`getStaticPaths`と`getStaticProps`を使って静的ページを生成し、Next.js Imageで画像を最適化しますので、引き続きお楽しみに！

---

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