Next.jsとSupabaseでオンライ学習プラットフォーム（LMS）を構築する | エピソード2: ユーザー認証システムの構築

こんにちは！前回のエピソードでは、Next.jsとSupabaseを使ってLMSプロジェクトの環境をセットアップし、シンプルなホームページを作成しました。今回は、Supabase Authを活用してユーザー認証システムを構築します。メールアドレスでの登録・ログイン、Google OAuth認証、フォームバリデーション（React Hook Form）、およびプロテクトルートの設定を行います。これで、ユーザーが安全にアプリを利用できる基盤が整います！

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

- Supabase AuthでメールとGoogle OAuth認証を実装。
- React Hook Formを使った登録・ログインフォームを構築。
- Supabaseクライアントでユーザーセッションを管理。
- Next.jsでプロテクトルートを設定。

## 必要なもの

- 前回のプロジェクト（`next-lms`）がセットアップ済み。
- Supabaseプロジェクト（認証設定済み）。
- `react-hook-form`と`@supabase/auth-helpers-nextjs`パッケージ。
- 基本的なTypeScript、React、Next.jsの知識。

---

## ステップ1: Supabase Authの設定

Supabase Authを有効化し、メールとGoogle OAuth認証を設定します。

1. **Supabaseダッシュボードでの設定**：
   - [Supabaseダッシュボード](https://supabase.com/)にログイン。
   - 「Authentication」→「Providers」で以下を設定：
     - **Email**: 有効化（デフォルトでON）。
     - **Google**: 有効化し、Google Cloud ConsoleでOAuthクライアントIDとシークレットを生成後、入力。
   - 「Settings」→「Redirect URLs」にリダイレクトURLを追加（例: `http://localhost:3000/api/auth/callback`）。

2. **必要なパッケージのインストール**：
   Supabase認証ヘルパーとフォームライブラリをインストール：

```bash
npm install @supabase/auth-helpers-nextjs react-hook-form
```

---

## ステップ2: Supabase Authクライアントの設定

Supabaseの認証をNext.jsで管理するために、ヘルパーライブラリを設定します。

1. **認証クライアントの初期化**：
   `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 });
```

2. **認証APIルートの作成**：
   `src/app/api/auth/callback/route.ts`を作成して、OAuthリダイレクトを処理：

```ts
import { NextResponse } from 'next/server';
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get('code');

  if (code) {
    const supabase = createRouteHandlerClient({ cookies }, {
      supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL!,
      supabaseKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    });
    await supabase.auth.exchangeCodeForSession(code);
  }

  return NextResponse.redirect(new URL('/dashboard', request.url));
}
```

このコードは、OAuth認証後のコールバックを処理し、ユーザーをダッシュボードにリダイレクトします。

---

## ステップ3: 登録・ログインフォームの構築

React Hook Formを使って、登録とログインフォームを作成します。

1. **登録ページ**：
   `src/app/register/page.tsx`を作成：

```tsx
import { useForm } from 'react-hook-form';
import { supabase } from '@/lib/supabase';
import { useRouter } from 'next/navigation';

type RegisterForm = {
  email: string;
  password: string;
};

export default function Register() {
  const { register, handleSubmit, formState: { errors } } = useForm<RegisterForm>();
  const router = useRouter();

  const onSubmit = async (data: RegisterForm) => {
    const { error } = await supabase.auth.signUp({
      email: data.email,
      password: data.password,
    });
    if (error) {
      alert(error.message);
    } else {
      router.push('/dashboard');
    }
  };

  return (
    <main className="container mx-auto p-4 max-w-md">
      <h1 className="text-3xl font-bold mb-6">登録</h1>
      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
        <div>
          <label className="block text-sm font-medium">メールアドレス</label>
          <input
            type="email"
            {...register('email', { required: 'メールアドレスは必須です' })}
            className="w-full border p-2 rounded"
          />
          {errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
        </div>
        <div>
          <label className="block text-sm font-medium">パスワード</label>
          <input
            type="password"
            {...register('password', { required: 'パスワードは必須です', minLength: { value: 6, message: '6文字以上必要です' } })}
            className="w-full border p-2 rounded"
          />
          {errors.password && <p className="text-red-500 text-sm">{errors.password.message}</p>}
        </div>
        <button type="submit" className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90">
          登録
        </button>
      </form>
      <p className="mt-4">
        すでにアカウントをお持ちですか？{' '}
        <a href="/login" className="text-primary hover:underline">ログイン</a>
      </p>
    </main>
  );
}
```

2. **ログインページ**：
   `src/app/login/page.tsx`を作成：

```tsx
import { useForm } from 'react-hook-form';
import { supabase } from '@/lib/supabase';
import { useRouter } from 'next/navigation';

type LoginForm = {
  email: string;
  password: string;
};

export default function Login() {
  const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>();
  const router = useRouter();

  const onSubmit = async (data: LoginForm) => {
    const { error } = await supabase.auth.signInWithPassword({
      email: data.email,
      password: data.password,
    });
    if (error) {
      alert(error.message);
    } else {
      router.push('/dashboard');
    }
  };

  const signInWithGoogle = async () => {
    const { error } = await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: { redirectTo: 'http://localhost:3000/api/auth/callback' },
    });
    if (error) alert(error.message);
  };

  return (
    <main className="container mx-auto p-4 max-w-md">
      <h1 className="text-3xl font-bold mb-6">ログイン</h1>
      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
        <div>
          <label className="block text-sm font-medium">メールアドレス</label>
          <input
            type="email"
            {...register('email', { required: 'メールアドレスは必須です' })}
            className="w-full border p-2 rounded"
          />
          {errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
        </div>
        <div>
          <label className="block text-sm font-medium">パスワード</label>
          <input
            type="password"
            {...register('password', { required: 'パスワードは必須です' })}
            className="w-full border p-2 rounded"
          />
          {errors.password && <p className="text-red-500 text-sm">{errors.password.message}</p>}
        </div>
        <button type="submit" className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90">
          ログイン
        </button>
      </form>
      <button
        onClick={signInWithGoogle}
        className="mt-4 bg-gray-800 text-white px-6 py-3 rounded hover:bg-opacity-90 w-full"
      >
        Googleでログイン
      </button>
      <p className="mt-4">
        アカウントをお持ちでないですか？{' '}
        <a href="/register" className="text-primary hover:underline">登録</a>
      </p>
    </main>
  );
}
```

これらのコードは：
- React Hook Formでフォームバリデーションを実装。
- Supabase AuthでメールとGoogle認証を処理。
- 成功時にダッシュボードにリダイレクト。

---

## ステップ4: ダッシュボードとプロテクトルートの設定

認証済みユーザーのみアクセス可能なダッシュボードを作成し、プロテクトルートを実装します。

1. **ダッシュボードページ**：
   `src/app/dashboard/page.tsx`を作成：

```tsx
import { supabaseServer } from '@/lib/supabase';
import { redirect } from 'next/navigation';

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

  if (!user) {
    redirect('/login');
  }

  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">ダッシュボード</h1>
      <p>ようこそ、{user.email}さん！</p>
      <form
        action={async () => {
          'use server';
          await supabase.auth.signOut();
          redirect('/login');
        }}
      >
        <button className="mt-4 bg-red-500 text-white px-6 py-3 rounded hover:bg-opacity-90">
          ログアウト
        </button>
      </form>
    </main>
  );
}
```

2. **ナビゲーションの更新**：
   `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>
              {user ? (
                <>
                  <Link href="/dashboard" className="mr-4 hover:underline">ダッシュボード</Link>
                  <form
                    action={async () => {
                      'use server';
                      await supabase.auth.signOut();
                    }}
                    className="inline"
                  >
                    <button className="hover:underline">ログアウト</button>
                  </form>
                </>
              ) : (
                <>
                  <Link href="/login" className="mr-4 hover:underline">ログイン</Link>
                  <Link href="/register" className="hover:underline">登録</Link>
                </>
              )}
            </nav>
          </div>
        </header>
        {children}
      </body>
    </html>
  );
}
```

このコードは：
- 認証状態に応じてナビゲーションを動的に表示。
- 未認証ユーザーを`/login`にリダイレクト。
- ログアウト機能をサーバーアクションで実装。

---

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

1. SupabaseダッシュボードでGoogle OAuth設定とリダイレクトURLが正しいことを確認。
2. 開発サーバーを起動：

```bash
npm run dev
```

3. `http://localhost:3000`にアクセスし、以下の点を確認：
   - ヘッダーに「ログイン」と「登録」リンクが表示される。
   - `/register`でメールとパスワードを入力し、登録できる。
   - `/login`でメールログインとGoogleログインが機能する。
   - ログイン後、`/dashboard`にリダイレクトされ、メールアドレスが表示される。
   - 未認証状態で`/dashboard`にアクセスすると、`/login`にリダイレクトされる。
4. ログアウトボタンをクリックし、セッションが終了することを確認。

エラーがあれば、Supabaseの認証ログや`.env.local`の設定を確認してください。

---

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

このエピソードでは、Supabase Authを使ってユーザー認証システムを構築しました。メールとGoogle OAuth認証、React Hook Formによるバリデーション、プロテクトルートを実装し、安全なユーザー体験の基盤を整えました。

次回のエピソードでは、Supabase Databaseを使ってコース管理機能を実装します。`courses`テーブルの設計と、コース一覧ページ（CLP）の構築を行いますので、引き続きお楽しみに！

---

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