Next.jsとSupabaseでオンライ学習プラットフォーム（LMS）を構築する | エピソード8: パフォーマンス最適化とCore Web Vitalsの向上

こんにちは！前回のエピソードでは、Supabase Realtimeを使ってリアルタイムコメント機能を実装し、ユーザーのインタラクティブな学習体験を向上させました。今回は、LMSのパフォーマンスを最適化し、Core Web Vitals（CWV）のスコアを向上させます。Lighthouseでパフォーマンスを分析し、LCP（Largest Contentful Paint）、CLS（Cumulative Layout Shift）を改善、React Server Componentsを活用してクライアントサイドのJavaScriptを削減します。これで、アプリが高速でユーザー体験が向上します！

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

- Lighthouseでパフォーマンスを分析。
- LCPを改善（遅延読み込み、フォント最適化）。
- CLSを削減（レイアウトの安定化）。
- React Server Componentsを活用してJavaScriptを削減。
- Lighthouseスコア90以上を目指す。

## 必要なもの

- 前回のプロジェクト（`next-lms`）がセットアップ済み。
- Supabaseプロジェクト（既存のテーブル設定済み）。
- Lighthouse（Chrome DevTools）または類似ツール。
- `next/font`パッケージ（Next.js内蔵）。
- 基本的なTypeScript、React、Next.jsの知識。

---

## ステップ1: Lighthouseでパフォーマンス分析

まず、現在のアプリのパフォーマンスをLighthouseで分析します。

1. **Lighthouseの実行**：
   - 開発サーバーを起動（`npm run dev`）。
   - Chromeで`http://localhost:3000/courses`または`http://localhost:3000/courses/[id]`を開く。
   - DevTools（F12）→「Lighthouse」タブ→「Performance」を選択し、「Generate Report」を実行。
   - 結果を確認（スコア、LCP、CLS、TBTなどの指標）。

2. **主な問題点の特定**：
   - **LCP**: 画像やビデオの読み込みが遅い、フォントのレンダリング遅延。
   - **CLS**: 画像やコメントの動的読み込みによるレイアウトシフト。
   - **TBT（Total Blocking Time）**: クライアントサイドJavaScriptの実行時間。

---

## ステップ2: LCPの改善

LCP（Largest Contentful Paint）は、ページの主要コンテンツの読み込み時間を測定します。以下の方法で改善します。

1. **画像の遅延読み込み（Lazy Loading）**：
   `src/app/courses/page.tsx`のコース一覧で、`Image`コンポーネントの`loading="lazy"`を活用（すでに適用済み）。必要に応じて確認：

```tsx
<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"
/>
```

2. **フォントの最適化**：
   Next.jsの`next/font`を使って、カスタムフォントを最適化。`src/app/layout.tsx`を更新：

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

const inter = Inter({ subsets: ['latin'], display: 'swap' });

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

  return (
    <html lang="ja" className={inter.className}>
      <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>
  );
}
```

このコードは：
- Google Fontsの`Inter`を`next/font`で読み込み。
- `display: 'swap'`でフォントスワップを有効化し、FOUT（Flash of Unstyled Text）を防ぐ。

3. **ビデオの最適化**：
   `src/app/courses/[id]/page.tsx`のReact Playerで、プレロードを制御：

```tsx
<ReactPlayer
  url={lesson.signedUrl}
  controls
  width="100%"
  height="auto"
  className="rounded-lg"
  config={{ file: { attributes: { preload: 'metadata' } } }}
/>
```

`preload="metadata"`でビデオの初期読み込みを最小限に抑えます。

---

## ステップ3: CLSの削減

CLS（Cumulative Layout Shift）は、予期しないレイアウトのずれを測定します。以下の方法で削減します。

1. **画像に固定サイズを指定**：
   すべての`Image`コンポーネントに`width`と`height`を明示（すでに適用済み）。`src/app/courses/[id]/page.tsx`を確認：

```tsx
<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
/>
```

2. **コメントセクションのレイアウト安定化**：
   `src/components/CommentSection.tsx`で、コメント読み込み中のプレースホルダーを追加：

```tsx
'use client';

import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { supabase } from '@/lib/supabase';
import { Comment } from '@/lib/supabase';

type CommentForm = {
  content: string;
};

export default function CommentSection({ lessonId, userId }: { lessonId: string; userId?: string }) {
  const [comments, setComments] = useState<(Comment & { users: { email: string } })[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const { register, handleSubmit, reset, formState: { errors } } = useForm<CommentForm>();

  useEffect(() => {
    const fetchComments = async () => {
      setIsLoading(true);
      const { data } = await supabase
        .from('comments')
        .select('*, users!inner(email)')
        .eq('lesson_id', lessonId)
        .order('created_at', { ascending: true });
      setComments(data || []);
      setIsLoading(false);
    };
    fetchComments();

    const channel = supabase
      .channel(`comments:lesson:${lessonId}`)
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'comments',
          filter: `lesson_id=eq.${lessonId}`,
        },
        (payload) => {
          if (payload.eventType === 'INSERT') {
            setComments((prev) => [...prev, payload.new as Comment & { users: { email: string } }]);
          }
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, [lessonId]);

  const onSubmit = async (data: CommentForm) => {
    if (!userId) {
      alert('コメントするにはログインしてください');
      return;
    }

    const { error } = await supabase
      .from('comments')
      .insert({
        lesson_id: lessonId,
        user_id: userId,
        content: data.content,
      });

    if (error) {
      alert(error.message);
    } else {
      reset();
    }
  };

  return (
    <div className="mt-6">
      <h3 className="text-xl font-semibold mb-4">コメント</h3>
      {userId ? (
        <form onSubmit={handleSubmit(onSubmit)} className="mb-6">
          <textarea
            {...register('content', { required: 'コメントを入力してください' })}
            className="w-full border p-2 rounded"
            rows={4}
            placeholder="コメントを入力..."
          />
          {errors.content && <p className="text-red-500 text-sm">{errors.content.message}</p>}
          <button
            type="submit"
            className="mt-2 bg-primary text-white px-4 py-2 rounded hover:bg-opacity-90"
          >
            投稿
          </button>
        </form>
      ) : (
        <p className="text-gray-600 mb-4">
          コメントするには<a href="/login" className="text-primary hover:underline">ログイン</a>してください。
        </p>
      )}
      <div className="space-y-4">
        {isLoading ? (
          Array.from({ length: 3 }).map((_, index) => (
            <div key={index} className="border-l-4 pl-4 py-2 animate-pulse">
              <div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
              <div className="h-4 bg-gray-200 rounded w-1/2"></div>
            </div>
          ))
        ) : comments.length > 0 ? (
          comments.map((comment) => (
            <div key={comment.id} className="border-l-4 pl-4 py-2">
              <p className="text-sm text-gray-500">{comment.users.email} - {new Date(comment.created_at).toLocaleString()}</p>
              <p>{comment.content}</p>
            </div>
          ))
        ) : (
          <p className="text-gray-600">コメントはまだありません。</p>
        )}
      </div>
    </div>
  );
}
```

このコードは：
- コメント読み込み中にプレースホルダーを表示し、CLSを防止。
- `animate-pulse`で視覚的なフィードバックを提供。

---

## ステップ4: React Server Componentsの活用

クライアントサイドのJavaScriptを削減するため、React Server Components（RSC）を最大限活用します。

1. **CDPのRSC確認**：
   `src/app/courses/[id]/page.tsx`はすでにRSCを使用（サーバーコンポーネント）。クライアントコンポーネント（`CommentSection`, `ReactPlayer`）は必要最低限に限定：

```tsx
// 既存のコード（省略）
// 'use client'なしでサーバーコンポーネントとして動作
```

2. **チェックアウトページの最適化**：
   `src/app/checkout/page.tsx`をサーバーコンポーネント中心に書き換え：

```tsx
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';
import CheckoutForm from '@/components/CheckoutForm';

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

  if (!user) {
    return (
      <main className="container mx-auto p-4">
        <h1 className="text-3xl font-bold mb-6">チェックアウト</h1>
        <p className="text-red-500">購入するにはログインしてください。</p>
        <a href="/login" className="text-primary hover:underline">ログイン</a>
      </main>
    );
  }

  return (
    <main className="container mx-auto p-4 max-w-md">
      <h1 className="text-3xl font-bold mb-6">チェックアウト</h1>
      <CheckoutForm userId={user.id} />
    </main>
  );
}
```

`src/components/CheckoutForm.tsx`（クライアントコンポーネント）：

```tsx
'use client';

import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';

type CheckoutForm = {
  courseId: string;
};

export default function CheckoutForm({ userId }: { userId: string }) {
  const { register, handleSubmit, formState: { errors } } = useForm<CheckoutForm>();
  const router = useRouter();
  const searchParams = useSearchParams();
  const courseId = searchParams.get('courseId');

  const onSubmit = async (data: CheckoutForm) => {
    const response = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ courseId: data.courseId }),
    });

    const result = await response.json();
    if (result.url) {
      router.push(result.url);
    } else {
      alert(result.error || '決済処理に失敗しました');
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label className="block text-sm font-medium">コースID</label>
        <input
          type="text"
          defaultValue={courseId || ''}
          {...register('courseId', { required: 'コースIDは必須です' })}
          className="w-full border p-2 rounded"
        />
        {errors.courseId && <p className="text-red-500 text-sm">{errors.courseId.message}</p>}
      </div>
      <button type="submit" className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90 w-full">
        決済に進む
      </button>
    </form>
  );
}
```

このコードは：
- サーバーコンポーネントで認証チェックを処理。
- クライアントコンポーネントをフォーム処理に限定し、JavaScriptを削減。

---

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

1. 開発サーバーを起動：

```bash
npm run dev
```

2. Lighthouseを再実行（`http://localhost:3000/courses`と`/courses/[id]`）：
   - LCPが2.5秒未満（フォントと画像の最適化を確認）。
   - CLSが0.1未満（プレースホルダーと固定サイズを確認）。
   - パフォーマンススコアが90以上。
3. ページを確認：
   - コース一覧と詳細ページが高速に読み込まれる。
   - コメントセクションにレイアウトシフトがない。
   - ビデオプレーヤーの読み込みがスムーズ。
4. ネットワークタブでJavaScriptのサイズを確認（RSCにより削減されている）。

エラーがあれば、Lighthouseの診断メッセージやSupabaseのクエリログを確認してください。

---

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

このエピソードでは、LMSのパフォーマンスを最適化し、Core Web Vitalsのスコアを向上させました。Lighthouseで分析を行い、LCPを遅延読み込みとフォント最適化で改善、CLSをレイアウト安定化で削減、React Server ComponentsでJavaScriptを削減しました。これで、アプリが高速でユーザー体験が向上しました！

次回のエピソードでは、LMSをProgressive Web App（PWA）に変換します。`next-pwa`を使ってオフライン機能やプッシュ通知を追加し、モバイルでのネイティブアプリのような体験を提供しますので、引き続きお楽しみに！

---

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