1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

こんにちは!前回のエピソードでは、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ダッシュボードにログイン。
    • 「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)。
  1. テストデータの挿入
    Supabaseの「Table Editor」またはSQL Editorでテストデータを追加:
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にコース取得関数を追加:
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を作成:

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を更新:

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. 開発サーバーを起動:
npm run dev
  1. http://localhost:3000/coursesにアクセスし、以下の点を確認:
    • コース一覧がカード形式で表示される。
    • タイトル、説明、価格が正しく表示される。
    • ページネーションの「前へ」「次へ」ボタンが機能する(データが多い場合)。
    • モバイルデバイスでもレスポンシブに表示される。
  2. ヘッダーの「コース」リンクをクリックし、/coursesに遷移することを確認。

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


まとめと次のステップ

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

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


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

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?