【AI駆動開発】Cursorを使いこなして1ヶ月でプログラミング学習サイトを作ったのでノウハウを伝えたい


こんにちは、とまだです。

皆さん、最近話題の **AI 駆動開発**、試していますか？

私は普段フリーランスの Web エンジニアとして活動しており、そちらでは AI エージェントをフル活用した開発を行っています。

そして業務だけでなく、個人開発の中でも AI を活用して、開発効率を劇的に向上させています。

そんな中、個人開発として **Cursor** と **Claude** をフル活用して、プログラミング学習サイトを **たった 1 ヶ月で作り切る** ことができました。

今回作ったのは「[Learning Next](https://school.learning-next.app/)」というサイトで、プログラミング学習用のテキスト、練習問題、ランキング、ユーザー登録、ダッシュボードなど、本格的な学習プラットフォームになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/2253d75a-da98-4f2f-a6a1-a603eb47644f.png)

本記事では、そんな中でやってきたことや、具体的な Cursor の設定方法、AI 駆動開発のノウハウをお伝えできればと思います。

ちなみに私は Claude を併用しましたが、Cursor だけでも十分に開発は可能です。

また、GitHub Copilot や Cline など、他の AI エージェントでも今回ご紹介する方法は応用可能です！

【追記：続編を書きました】

- [【コピペ OK】個人開発で Apple 風デザインルールを作ったら統一感のあるカッコいい UI にできた話](https://qiita.com/tomada/items/decece613046a61b11a3)

## この記事でわかること

- Cursor と Claude を使った効率的な AI 駆動開発の手法
- 実際に使った `.cursorrules` の設定方法
- タスク分解とドキュメント化で開発を円滑に進める方法
- AI が間違えやすいポイントと対策
- 1 ヶ月で本格的な Web アプリを作るための戦略

特に **「AI を使った開発に興味があるけど、どうやって始めればいいかわからない」** という方には、具体的なノウハウをお伝えできると思います。

## 忙しい人のために要約

- Cursor と Claude を使って、1 ヶ月でプログラミング学習サイトを開発
- Cursor の `.cursorrules` を紹介するので参考にしてね
- AI 駆動開発のポイントは「タスク分解」と「ドキュメント化」
- 要件定義と設計を事前に行うことで、AI の誤解を減らす
- ↑ をうまくこなすためのプロンプトも紹介してるよ
- コンポーネントの粒度やデザインの統一感の観点で反省点があるが、全体的には成功

## 今回作った Learning Next の機能

まず、どんなサイトを作ったのか、そして技術的なポイントを簡単に紹介します。

技術的なポイントや、個人開発での機能設計の考え方なども含めてお伝えしますので、個人開発に興味がある方はぜひ最後まで読んでみてください。

本業で数十万のユーザーを抱える Web サイト開発に携わってきた経験を活かしています。

（AI 駆動開発の話だけを知りたいという方は読み飛ばしてもらっても大丈夫です）

### 学習コンテンツ機能

#### Ruby、Rails、RSpec、JavaScript、React の学習テキスト

当サイトのメイン学習コンテンツです。

- [Learning Next: 学習コース一覧](https://school.learning-next.app/docs)

![スクリーンショット 2025-06-15 7.00.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/dd7a22e2-9b79-4bcd-9d60-a1a3edba625c.png)

僭越ながら [Udemy](https://www.udemy.com/user/zeng-shan-you-si) でベストセラーの講師をしている私が、実際の講座で使っているテキストをベースにしています。

（テキスト自体はこれまで地道に作ってきたものであり、AI 駆動開発とは関係ないので割愛）

今回は [React Markdown](https://github.com/remarkjs/react-markdown) を使って実装しました。

綺麗なコードブロック、しかもスマホ幅に対応するなどは意外と大変だったので、これからマークダウン対応のサイトを作る方は表示テストに時間をかけることをおすすめします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/aa2e45f8-b62c-4c34-af60-48e284a60647.png)

<details><summary>参考：React Markdown 用にカスタマイズしたコンポーネント</summary>

```tsx
'use client';

import Prism from 'prismjs';
import 'prismjs/themes/prism-tomorrow.css';
import React, { useCallback, useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';

// 必要な言語をインポート
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-css';
import 'prismjs/components/prism-java';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-jsx';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-ruby';
import 'prismjs/components/prism-tsx';
import 'prismjs/components/prism-typescript';

type MarkdownRendererProps = {
  content: string;
  className?: string;
};

// コード要素のprops型を定義
interface CodeProps {
  className?: string;
  children?: React.ReactNode;
  [key: string]: unknown;
}

/**
 * マークダウンデータの前処理
 * エスケープされた文字を実際の文字に変換
 */
function preprocessMarkdown(content: string): string {
  return content
    .replace(/\\n/g, '\n')
    .replace(/\\t/g, '\t')
    .replace(/\\r/g, '\r')
    .replace(/\\\\/g, '\\')
    .replace(/\\"/g, '"')
    .replace(/\\'/g, "'");
}

/**
 * 汎用的なマークダウンレンダリングコンポーネント
 * 問題文や解説文など、様々な用途で使用可能
 */
export default function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const highlightTimeoutRef = useRef<NodeJS.Timeout>();

  // マークダウンコンテンツの前処理
  const processedContent = React.useMemo(() => {
    return preprocessMarkdown(content);
  }, [content]);

  // Prism.jsでハイライトを適用する関数
  const highlightCode = useCallback(() => {
    if (containerRef.current && typeof window !== 'undefined') {
      // 既存のハイライトをクリア
      const preElements = containerRef.current.querySelectorAll('pre');
      preElements.forEach((pre) => {
        const code = pre.querySelector('code');
        if (code) {
          // Prismが追加したクラスを保持しつつ、トークンをクリア
          const className = code.className;
          const textContent = code.textContent || '';
          code.innerHTML = '';
          code.textContent = textContent;
          code.className = className;
        }
      });

      // 新しくハイライトを適用
      Prism.highlightAllUnder(containerRef.current);
    }
  }, []);

  // コンテンツ変更時とマウント時にハイライトを適用
  useEffect(() => {
    // 既存のタイムアウトをクリア
    if (highlightTimeoutRef.current) {
      clearTimeout(highlightTimeoutRef.current);
    }

    // 複数回のハイライト適用で確実性を高める
    const applyHighlight = () => {
      highlightCode();

      // 追加の保険として、少し遅れて再度適用
      highlightTimeoutRef.current = setTimeout(() => {
        highlightCode();
      }, 300);
    };

    // 初回のハイライト適用
    const initialTimeout = setTimeout(applyHighlight, 50);

    return () => {
      clearTimeout(initialTimeout);
      if (highlightTimeoutRef.current) {
        clearTimeout(highlightTimeoutRef.current);
      }
    };
  }, [processedContent, highlightCode]);

  // MutationObserverでDOM変更を監視
  useEffect(() => {
    if (!containerRef.current) return;

    const observer = new MutationObserver((mutations) => {
      // コードブロックに関する変更があった場合のみハイライトを再適用
      const hasCodeChanges = mutations.some((mutation) => {
        if (mutation.type === 'childList') {
          const addedNodes = Array.from(mutation.addedNodes);
          const removedNodes = Array.from(mutation.removedNodes);
          return [...addedNodes, ...removedNodes].some((node) => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              const element = node as Element;
              return (
                element.tagName === 'PRE' ||
                element.tagName === 'CODE' ||
                element.querySelector('pre') ||
                element.querySelector('code')
              );
            }
            return false;
          });
        }
        return false;
      });

      if (hasCodeChanges) {
        highlightCode();
      }
    });

    observer.observe(containerRef.current, {
      childList: true,
      subtree: true,
    });

    return () => {
      observer.disconnect();
    };
  }, [highlightCode]);

  return (
    <div ref={containerRef} className={`prose max-w-none ${className}`}>
      <div className="markdown-content">
        <ReactMarkdown
          remarkPlugins={[remarkGfm]}
          rehypePlugins={[rehypeRaw]}
          components={{
            // コードブロックの処理
            pre: ({ children, ...props }) => {
              const childArray = React.Children.toArray(children);
              const codeElement = childArray.find((child) => React.isValidElement(child) && child.type === 'code');

              if (codeElement && React.isValidElement(codeElement)) {
                const codeProps = codeElement.props as CodeProps;
                const className = codeProps.className || '';

                // 言語とファイル名の抽出
                const languageMatch = className.match(/language-(\w+)(?::(.+))?/);
                const language = languageMatch ? languageMatch[1] : 'plaintext'; // 言語指定がない場合はplaintext
                const fileName = languageMatch && languageMatch[2] ? languageMatch[2] : '';

                // codeElementのpropsからinline-codeを削除（コードブロック内では使わない）
                const newCodeProps = { ...codeProps };
                if (newCodeProps.className === 'inline-code') {
                  newCodeProps.className = 'language-plaintext';
                }

                return (
                  <div className={`code-block ${language === 'plaintext' ? 'code-block-plaintext' : ''}`}>
                    {fileName && (
                      <div className="code-filename">
                        <span>{fileName}</span>
                      </div>
                    )}
                    <pre {...props} className={`language-${language}`} data-prism-processed="false">
                      {React.cloneElement(codeElement, {
                        className: `language-${language}`,
                        ...newCodeProps,
                      })}
                    </pre>
                  </div>
                );
              }

              // 言語指定のないpreタグは特別なクラスで扱う
              return (
                <div className="code-block code-block-plaintext">
                  <pre {...props} className="language-plaintext" data-prism-processed="false">
                    {children}
                  </pre>
                </div>
              );
            },

            // インラインコードの処理
            code: ({ className, children, ...props }) => {
              const content = String(children).replace(/\n$/, '');
              const match = className?.match(/language-(\w+)/);

              // 親要素がpreかどうかを確認する関数
              const isInsidePre = () => {
                // ブラウザ環境でのみ判定可能
                if (typeof document !== 'undefined' && props.node) {
                  // ReactMarkdownのnode型は複雑なので、より安全な方法で処理
                  type ReactMarkdownNode = {
                    parentNode?: {
                      tagName?: string;
                    };
                  };

                  // unknownを経由してキャスト
                  const node = props.node as unknown as ReactMarkdownNode;
                  if (node.parentNode?.tagName === 'PRE') {
                    return true;
                  }
                }
                // コンテンツの改行数で判断（複数行ならコードブロック内と推測）
                const textContent = String(children);
                return textContent.includes('\n') || textContent.length > 80;
              };

              // インラインコードの場合
              if (!match) {
                // 親がpreの場合はplaintextとして扱う
                if (isInsidePre()) {
                  return (
                    <code className="language-plaintext" {...props}>
                      {children}
                    </code>
                  );
                }

                // 通常のインラインコード
                return (
                  <code className="inline-code" {...props}>
                    {children}
                  </code>
                );
              }

              // コードブロック内のコード要素
              const language = match[1];
              return (
                <code className={`language-${language}`} {...props}>
                  {content}
                </code>
              );
            },

            // その他の要素のカスタマイズ（オプション）
            p: ({ children }) => <p className="mb-4 leading-relaxed text-gray-700 last:mb-0">{children}</p>,
            h1: ({ children }) => <h1 className="mb-4 text-2xl font-bold text-gray-900 sm:text-3xl">{children}</h1>,
            h2: ({ children }) => <h2 className="mb-3 text-xl font-semibold text-gray-900 sm:text-2xl">{children}</h2>,
            h3: ({ children }) => <h3 className="mb-3 text-lg font-semibold text-gray-900 sm:text-xl">{children}</h3>,
            ul: ({ children }) => <ul className="mb-4 space-y-1 pl-6">{children}</ul>,
            ol: ({ children }) => <ol className="mb-4 space-y-1 pl-6">{children}</ol>,
            li: ({ children }) => <li className="text-gray-700">{children}</li>,
            blockquote: ({ children }) => (
              <blockquote className="my-4 border-l-4 border-blue-400 bg-blue-50 py-2 pl-4 text-gray-700 italic">
                {children}
              </blockquote>
            ),
            a: ({ children, href }) => (
              <a
                href={href}
                className="text-blue-600 underline hover:text-blue-800 hover:no-underline"
                target="_blank"
                rel="noopener noreferrer"
              >
                {children}
              </a>
            ),
          }}
        >
          {processedContent}
        </ReactMarkdown>
      </div>
    </div>
  );
}
```

</details>

#### 学習状況のダッシュボード

ログインしていれば、自分の学習状況をダッシュボードで確認できます。

- [Learning Next: 学習進捗ダッシュボード](https://school.learning-next.app/dashboard/lesson-progress)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/f28f211c-1d39-4461-b2da-fb65dc7c3f30.png)

#### 一部、有料ユーザー限定の詳細コンテンツ

後述しますが、今回は **Clerk** を使ってユーザー認証を実装し、無料ユーザーと有料ユーザーの権限を分けています。

テキストは一部、有料ユーザー限定となっているので、ゲストや無料ユーザーに対してはコンテンツガードをかけています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/e8349ce3-12b9-46e7-9064-4239d9cec229.png)

これは Clerk の [Protect](https://clerk.com/docs/components/protect) コンポーネントを使って実装しています。

ただ完全に Clerk にお任せというわけではなく、ページやプラン、さらには部分的に制御を行うため、専用のユーティリティ関数を作成して、必要な部分でガードをかけています。

一部、実装を紹介します。

```typescript
/**
 * ユーザープランとパスからアクセス可否を判定する関数
 * @param planName - ユーザーの現在のプラン ('basic', 'premium', または null)
 * @param path - 現在のパス
 * @returns 閲覧可能かどうかの真偽値
 */
export const canAccessContent = (planName: string | null, path: string): boolean => {
  const requiredPlan = getRequiredPlan(path) // 内部関数でパスに応じた必要プランを取得

  // アクセス制限がない場合は誰でも閲覧可能
  if (!requiredPlan) {
    return true
  }

  // ユーザーにプランがない場合はアクセス不可
  if (!planName) {
    return false
  }

  // プラン階層に基づいたアクセス制御
  if (requiredPlan === PLAN_SLUG.STARTER) {
    // starter以上のプランを持つユーザーがアクセス可能
    return [PLAN_SLUG.STARTER, PLAN_SLUG.BASIC, PLAN_SLUG.PREMIUM].includes(planName as PlanLevel)
  }

  // ...
```

今回の設計としては、できるだけアプリにおける重要ロジックはコンポーネントの外、つまりユーティリティ関数やサービス層で実装し、コンポーネントは UI 表示に専念する形をとっています。
それなりに巨大なアプリケーションになってきたので、コンポーネントの責務を明確に分けることで、保守性を高めています。

### 練習問題機能

さて、Learning Next のメイン機能の一つが **練習問題** です。

- [Learning Next: 練習問題一覧](https://school.learning-next.app/quiz)

現在は **1,300 問以上** の問題を用意しており、各コースごとに問題が出題されます。

分野も Ruby、Rails、RSpec、JavaScript、React、TypeScript など多岐にわたっており、現在 Python も準備中です。

<img height="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/7467cc92-bcf8-46c8-9d47-0d920308dcdf.png">

今後は、問題に対してユーザーからのフィードバックを受け付けたり、記述式の問題も追加したいと考えています。

#### コース、トピック、不正解でフィルタリング

問題はコースやトピックごとにフィルタリングでき、さらに不正解の問題だけを集中的に学習することも可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/e8d021f2-a195-4614-bdd7-1997119b37e1.png)

#### 分野ごとに実績をダッシュボード管理

ログインして練習問題を解くと練習問題ごとに正誤が記録されるので、ダッシュボードで各分野の学習進捗や正解率を確認できます。

- [Learning Next: 練習問題ダッシュボード](https://school.learning-next.app/dashboard/course/react)

（詳細は後述）

#### タイムアタックモード

ただクイズを解くだけでなく、**タイムアタックモード** も用意しており、コンパクトに **1 分間** でのスコアを競うことができます。

- [Learning Next: タイムアタック](https://school.learning-next.app/quiz)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/bb6eca9f-b7a1-4fba-8d53-542f48380512.png)

解いた後は **TechRate** という独自スコアが計算され、ランキングに反映されて他ユーザーと競うことができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/78cf4d86-1fec-4a21-b200-4b6db3d38093.png)

ただ学習するだけじゃなく、ゲーミフィケーション要素があったら楽しいと思い、スマブラ SP の「世界戦闘力」から着想を得て実装しました。

ちなみに「正解数」だけではなく「正解率」なども考慮しているので、当てずっぽうではなく、短時間で以下に正確に解けるかが重要です。

### ランキング機能

一人で学習するだけではなく、他のユーザーと競い合うためのランキング機能も実装しています。

- [Learning Next: ランキング](https://school.learning-next.app/ranking)

#### 総合ランキング・コース別ランキング

まずは、練習問題の正解数を集計した **総合ランキング** と、各コースごとの **コース別ランキング** です。

純粋に学習量が多いユーザーが上位に来るので、学習のモチベーションになるかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/e0ad7eb2-f27e-42e8-a12d-6577415ade31.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/bc4b6fa2-536f-476c-bb16-1644452028eb.png)

今ならユーザーも少なく、ランカーになりやすいので挑戦してみませんか！？（ド宣伝）

#### タイムアタックのスコアランキング

タイムアタックモードでのスコアを競う **TechRate ランキング** も用意しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/83024b61-23dc-4d0f-887d-6754a17e3eb5.png)

ここでこだわったのは「新規ユーザーでもランキングに載るチャンスを作ること」です。

古参がずーっと上位を独占してしまうと、新規ユーザーにとってモチベーションになりづらいですからね！

そのため、「期間別」や「コース別」など、より細分化することで、誰でも上位に入れるチャンスを作りました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/7393b086-07ad-44be-ae3d-8cfba082289e.png)

#### 余談：データベースのセキュリティ

Learning Next ではデータベースに **Supabase** を使っています。

Supabase では RLS （Row Level Security）を使って、ユーザーごとにデータのアクセス制御を行っているのですが、ランキングのように他ユーザーのデータを参照する場合、RLS の設定が少し複雑になります。

そのあたりを細かく設定するのは大変なので、ランキングのデータは「ビュー」を使って、ユーザーごとに必要なデータだけを集計して表示しています。

ビューとは簡単に言うと、事前に定義したクエリの結果をテーブルのように扱えるものです。

**例（実際のビューとは違います）**

```sql
CREATE VIEW user_rankings AS
SELECT user_id, COUNT(*) AS correct_answers
FROM quiz_answers
WHERE is_correct = true
GROUP BY user_id
```

このようにビューを使うことで、RLS の制約を受けずに、必要なデータだけを簡単に取得できるようになります。

また、必ず固定の情報しか取得しないので、セキュリティ的にも安心です。

今後、Supabase を使ってユーザー参加型のアプリケーションを作る場合、RLS とビューの組み合わせは非常に有効な手段だと思います。

- [参考: Supabase - Views](https://supabase.com/docs/guides/graphql/views)

### ダッシュボード機能

Learning Next では、ユーザーが自分の学習状況を把握できる **ダッシュボード** 機能も実装しています。

- [Learning Next: ダッシュボード](https://school.learning-next.app/dashboard)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/d0bab4d8-5bf4-4d17-8b02-cb40d077a353.png)

通常のプログラミング学習サイトやスクールでは、学習進捗の可視化などだけで終わることが多いですが、Learning Next では以下のような機能を実装しています。

#### テキスト学習進捗の可視化

ダッシュボード内で、テキストベースの学習進捗を可視化する機能を提供しています。これにより、ユーザーは自分の学習状況を一目で把握できるようになります。

コースごと、さらにチャプター、レッスンごとに学習進捗を可視化でき、全体の達成率や未学習のレッスンを確認できます。

「埋めるべき箇所が一目でわかる」ので、学習のモチベーションにもつながるのではないかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/1e9555bb-9184-4778-91f6-e9681ecba927.png)

#### コース別の練習問題達成率

ダッシュボードでは、各コースごとの練習問題の達成率も表示しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/0eedf672-abe7-4a4a-a94f-dc6ccbd306d8.png)

また、トピックごとに学習進捗を可視化し、どの分野が得意でどの分野が苦手かを把握できるので、あとで重点的に学習することができます。

個人的に、ゲームでも実績を埋めていくのが大好きなので、一番こだわった機能かもしれません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/af0a80c8-9398-49e9-8a49-e65ef5c474f2.png)

#### 連続学習記録（ストリーク）

皆さんは「ストリーク」という言葉を聞いたことがありますか？

これは「目標を達成するために、連続して行動を続けること」を指すそうです。

2016 年に Apple Design Award を受賞した [Streaks](https://streaksapp.com/) というアプリが有名でして、私も英語学習に愛用しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/5e900d19-44f5-42f6-9719-578e020019f4.png)

機能は至ってシンプルで、 **あらかじめ決めた目標を毎日こなし、登録するだけ** です。
1 日の終わりになっても目標を達成していない場合、アプリが通知でリマインドしてくれます。

ある程度連続記録が伸びてくると、途切れさせたくないという心理が働くので、意地でも目標を達成しようとします。

どれくらいかというと、合計 20 時間の長距離フライト移動を終えた後でも、空港のベンチでわざわざ英語学習をして、ストリークを途切れさせないようにしたくらいです。

そんな実体験をもとに、Learning Next ではユーザーの **連続学習日数** をダッシュボードで可視化しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/e21e1ced-eec0-4e92-96ca-af4ca3164aca.png)

学習記録が伸びていくと数字やアイコンが変化していき、達成感を感じられるようになっています。

ただ、Learning Next はあくまで Web アプリであり、プッシュ通知ができないため、ユーザーに学習を促すリマインダー機能は実現できていません。

このあたりはやはりモバイルアプリの方が得意なところですね。

ユーザーへのリマインダー機能は、今後の課題として考えています。

### 認証・課金機能

さて、ここまでは「機能」を中心に紹介してきましたが、Learning Next では **認証** と **課金** の機能も実装しています。

#### Clerk による認証機能

認証周りは完全に **Clerk** にお任せしています。

- [Clerk: Next.js Quickstart](https://clerk.com/docs/quickstarts/nextjs)

これがほんとーーーーーに楽でして、モーダルでの新規登録、ログイン、パスワードリセットなど、必要な機能は全て用意されています。

必要な設定も少なく、15 分もあれば以下のようなモーダル内ログインをサクッと実装できちゃいます。

しかも Google、GitHub、Apple などの外部認証も簡単に組み込めるので、ユーザーにとっても便利です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/b55b5b05-d510-4c90-9a1d-2bdeec338673.png)

もちろん、モーダル内だけでなく、個別ページとしての新規登録やログインページも用意できます。

- [Learning Next: ログインページ](https://school.learning-next.app/sign-in)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/3d311e08-c786-4f5f-9b9e-bfb15b80e591.png)

嬉しいのが、たとえば Next.js であれば以下の数行だけで最低限のログインページを作れるということです。

```tsx
import { SignIn } from '@clerk/nextjs';

export default function Page() {
  return <SignIn />;
}
```

もちろん、デザインや機能をカスタマイズしたい場合は、Clerk のコンポーネントを使って自由に実装できます。

そのやり方も Clerk の公式ドキュメントに書かれています。

- [参考: Clerk - Next.js カスタムサインイン/サインアップページ](https://clerk.com/docs/references/nextjs/custom-sign-in-or-up-page)

Next.js 以外にも React, iOS, Vue.js, Expo, Rails, Python, Go などなど、さまざまなプラットフォームで利用できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/1d7f7c20-6d2c-43f4-b1e2-1027eb0e37d8.png)

今後、私が認証機能を作るとなったら Clerk を選ぶと思います。

一応、日本語化もできるようなのですが、後述する課金機能は未対応のようで、カスタマイズが必要そうだったので一旦導入は後回しにしました。

- [参考: Clerk LOcalization](https://clerk.com/docs/customization/localization)

#### サブスクリプション課金

Learning Next では学習テキストの一部を有料化しており、**サブスクリプション課金** を実装しています。

本来、Stripe や Pay.jp などの決済サービスを使うことが多いのですが、今回は **Clerk** のサブスクリプション機能を利用しました。

- [Clerk: サブスクリプションの実装](https://clerk.com/billing)

通常であれば、Stripe などの決済サービスを使う場合、以下のようなことを自分で実装する必要があります。

- ユーザーのサブスクリプション状態の管理
- 課金プランの作成・更新
- 課金履歴の管理
- 課金失敗時のエラーハンドリング
- 課金成功時のユーザーへの通知
- 課金プランの変更やキャンセルの処理
- 年間プランの割引適用

一方、Clerk を使うと、これらの処理をほとんど自動で行ってくれます。

Stripe アカウントの用意も Clerk 設定の一部として行えるので、手間が大幅に削減されます。

手数料は `0.7%` (執筆時点) だけですので、開発工数を考えると、十分にコストパフォーマンスが良いと思います。

現在はベータ版ということで、日本円には対応していなかったり、プラン料金の変更に制限があったりしますが、今後のアップデートに期待しています。

また、プランごとに「できること」をダッシュボードで設定できるのも楽ちんです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/539d6089-b406-4f1d-b637-d74281002fcc.png)

さらに嬉しいのが、サブスクリプション登録などのコンポーネントも用意されていることです。

Clerk で設定した情報を自動的に反映し、こんな感じの画面が簡単に実装できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/295246de-4740-4ea7-8381-3d0910876c01.png)

カード情報の入力コンポーネントなども用意されているので、決済周りの実装はすべて Clerk にお任せしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364501/1b75cd96-c5b5-4f68-9cf2-9a09d0775295.png)

余談ですが、本業の現場で Stripe の課金機能を実装したこともあるのですが、あれは本当に大変でした。

絶対にバグがあってはいけないし、検証も大変だし、サブスクリプションの更新やキャンセル、決済失敗時の処理など、考慮すべきことが多すぎて地獄です。

それを Clerk がほとんど自動でやってくれるので、開発者としては本当に助かります。

先述の通りベータ版ということもあり、ちょっと物足りない点はあるもののひとまずはこれで十分です。

### 機能のまとめ

機能紹介はここまでです。

Web エンジニアの傍、プログラミングスクール講師や Udemy でプログラミング教育に携わってきたこともあって、かなり熱をこめて開発しました。

実は他にも **エンジニア適職診断** や **学習ロードマップ** などの機能も既に作ってあるのですが、UI の調整やデータの整備がまだ終わっていないので、今回は割愛します。

これだけの機能を **1 ヶ月で実装** できたのは、本当に Cursor 君と Claude のおかげだと思っています。

ここからは、実際に開発を進める中で感じたことや、AI 駆動開発のノウハウをお伝えします。

## AI 駆動開発の基本戦略

まず、私が今回採用した AI 駆動開発の基本的な流れを説明します。

開発の効率化と品質向上を図りましたので、今後のプロジェクトにも応用できると思います。

### 1. Claude で要件定義・基本設計

最初に **Claude** を使って、以下のことを整理しました。

- サービスの全体像と機能要件
- 技術構成とアーキテクチャ
- データベース設計
- API 設計

たとえば、以下のようなプロンプトを Claude に投げて、要件定義を行いました。
これは練習問題の進捗管理ダッシュボードを作ったときに、実際に投げたプロンプトです。

````markdown
# 役割

優秀なプロダクトマネージャー

# 背景

プログラミング学習用に、練習問題を解ける Web サイトを運営しています。
ユーザーは、Ruby や JavaScript などの分野ごとにクイズに挑戦し、学習を進めています。

ユーザーごとに、解いた問題の履歴を管理し、ダッシュボードでその進捗状況を表示する機能を追加したいと考えています。

関連するテーブルの構造は以下の通りです。

```sql
CREATE TABLE quizzes (
... 実際にはテーブル構造の SQL 定義が続く ...
```

# 目的

ユーザーが自分の学習進捗を把握できるようなダッシュボード機能の要件と基本設計を作ること。

# 命令

上記の背景、目的を満たすためのダッシュボード機能の要件を以下の観点から考えてください。

- これまで解いた問題回数（解いた問題数、ではなくクイズにチャレンジした回数）はわかる
- ruby, rspec など分野ごとに何問、そしてそれらの問題のうち何％を解いているかがわかる（つまり、進捗状況がわかる）
- それぞれ分野ごとに「配列」などトピックがあるので、それぞれも進捗がわかる
- レベル「初級・中級・上級」ともわかれてるので、それもわかる
- 全体として、自分の得意・不得意がわかる
- 頑張った分だけ他の人に自慢したくなるようにして、承認欲求を満たす
- Next.js の特性を利用してキャッシュを駆使して表示パフォーマンスをあげる
- 部分的に読み込むなど、パフォーマンスを意識した設計にする
- 自分の学習状況が見えるようなゲーミフィケーション要素を大事にしたい
````

もちろん、Cursor でも同様の要件定義を行うことはできますが、Claude は長い文脈にも対応できるので、より詳細な要件を引き出すことができます。

本来であればこういった要件定義は人間が考えるものですが、人間は「アイデアの種」だけを出して、あとはプロダクトマネージャーになりきった Claude に考えてもらうことで、より効率的に進めることができました。

この段階でしっかりと **要件定義と設計を固める** ことで、後の実装がスムーズになります。

### 2. 機能ごとにタスク分解

次に、大きな機能を **細かなタスクに分解** しました。

例えば「練習問題機能」なら、以下のように分解します。

- 問題表示コンポーネント
- 回答入力コンポーネント
- 結果表示コンポーネント
- 統計情報コンポーネント
- タイムアタック機能

このように、**一つ一つが独立して実装できるレベル** まで分解することが重要です。

ちなみに、タスク分解も Claude にお願いしました。

````markdown
# 役割

優秀なプロダクトマネージャー

# 背景

プログラミング学習用に、練習問題を解ける Web サイトを運営しています。
ユーザーは、Ruby や JavaScript などの分野ごとにクイズに挑戦し、学習を進められるようにします。

# 目的

練習問題機能を実装するためのタスクを分解し、実装しやすい形にすること。

# 命令

ステップバイステップで実装を行うために、練習問題機能を作成するためにタスクを分解してください。

# 参考

関連するテーブルの構造は以下の通りです。

```sql
...
```

# 制約事項

- Next.js 15 (App Router) + TypeScript + Tailwind CSS を使用
- Supabase をバックエンドに使用
- Clerk を認証に使用
- コンポーネントは関数コンポーネント + hooks
- CSS は Tailwind CSS のみ使用
- TypeScript の型安全性を最優先
- エラーハンドリングは必須
- ローディング状態の UI も実装
- レスポンシブ対応は必須（モバイルファースト）
````

このように、Claude にタスク分解を最初にしておくことで、Cursor も迷わずに実装に取り掛かることができたのではないかと思います。

今思えば、このタイミングで `.cursorrules` の中身も合わせて Claude に渡せば、既存の設計や方針に基づいてタスク分解をしてくれたかもしれません。

### 3. Cursor に基本設計書を渡して実装

各タスクについて、**Cursor** に以下を渡して実装してもらいます。

- 共通指示書（`.cursorrules`）
- 機能ごとの基本設計書
- 参考にすべき公式ドキュメント

この時のポイントは、**一気に全部作らせるのではなく、機能単位で区切る** ことです。

AI エージェントを使って開発をしたことがあるかたなら、途中で設計が微妙だったときの **手戻り地獄** を経験したことがあるかもしれません。
そのため、Cursor に渡す設計書は **できるだけ具体的に** し、かつ **小さな単位で実装を依頼** することが重要です。

なお、Cursor に渡す共通指示書 `.cursorrules` については後述します。

### 4. 実装後のドキュメント化

ここが一番、Cursor のような AI エージェントを使った開発で重要なポイントです。

機能が完成したら、必ず **ドキュメント化** することをおすすめします。

私の場合、たとえば `src/app/dashboard` といったページを作ったら、そこに `DASHBOARD_README.md` というドキュメントを置いています。
このドキュメントには、実装した機能の概要や設計、使い方などを詳しく書いています。

たとえば、以下のような点を盛り込んだドキュメントを Cursor に書かせました。

- 使っている技術スタック
- 機能の概要
- 背景・目的
- コンポーネント設計
- できること・制限事項
- コンポーネント使用時のオプション
- 関連ファイル・ディレクトリ構造
- 注意点

なぜドキュメント化が重要かというと、AI エージェントは **一度実装したコードを忘れてしまう** からです。
そのため、後から同じ機能を修正したり、別のプロジェクトで再利用する際に、ドキュメントがないと **何をどう実装したか思い出せない** という事態になります。

ちょっと一手間ではあるんですが、ドキュメント化を怠ると、後々の保守性や再利用性に大きな影響が出ます。
そのため、実装後は必ずドキュメント化することを強くおすすめします。

ちなみに私は、一通り機能を実装した後に、Cursor に以下のようなプロンプトを投げてドキュメントを書かせました。

```markdown
# 役割

優秀なフロントエンドエンジニア

# 背景

Learning Next というプログラミング学習プラットフォームを開発しています。

# 目的

Learning Next の練習問題機能の実装が完了しました。
この機能について、以下の内容を含むドキュメントを作成し、後にエンジニアが機能の修正や追加を行う際に迷わず開発できるようにします。

# 命令

以下の内容を含むドキュメントを作成してください。

- 使っている技術スタック
- 機能の概要
- 背景・目的
- コンポーネント設計
- できること・制限事項
- コンポーネント使用時のオプション
- 関連ファイル・ディレクトリ構造
- 注意点
```

あと、こういったドキュメントを作っておくと人間目線としても助かりました。

数日経つと自分が何を考えて実装したのか忘れてしまうので、ドキュメントを見返すことで **当時の意図を思い出せる** のは大きなメリットです。

## 要件定義・基本設計書を Cursor に渡して実装を依頼する流れ

色んなやり方があるかとは思いますが、私が今回採用した流れは以下の通りです。

1. Claude で要件定義・基本設計を行う
2. 作成した設計書をマークダウンファイルとして保存
3. Cursor に `.cursorrules` と設計書を渡して実装を依頼
4. 実装後、Cursor にドキュメント化を依頼

設計書がしっかりしていれば、Cursor に渡すプロンプトは最小限で済みます。

たとえば「渡している設計書に基づいて、練習問題機能を実装してください」といった感じです。

逆に言うと、設計書が不十分だと Cursor が迷ってしまい、実装が進まなくなる可能性がありますので、設計書はしっかりと作成しておくことが大事だなと感じました。

## 実際に使った .cursorrules の設定

ここからは、具体的なノウハウをお伝えします。

まず、Cursor での開発を効率化するために設定した `.cursorrules` です。

プロジェクト開始時に Claude をベースに作成しつつ、開発途中で Cursor 君がアホちゃんだった点を盛り込みながら調整しました。

これから Next.js / Tailwind CSS / TypeScript / Supabase など、似たような技術スタックで開発する方は参考になるかと思います。

とても長いので折りたたんでおきますね。

<details><summary>cursorrules</summary>

````markdown
# Next.js + TypeScript プロジェクトのコーディング規約

## 前提条件

- このプロジェクトは以下の技術スタックを使用しています：
  - Next.js 15 (App Router)
  - TypeScript 5.4+
  - Tailwind CSS (スタイリング)
  - Lucide Icons (アイコン)
  - Devicon (開発技術アイコン)

## ディレクトリ構造

- `src/app`: Next.js App Router のページ構造
- `src/components`: React コンポーネント
- `src/lib`: 外部ライブラリとの統合コード
- `src/hooks`: カスタム React Hooks
- `src/types`: グローバルな型定義
- `src/styles`: グローバルスタイルシート
- `src/utils`: ユーティリティ関数

## 基本コーディング規約

### 型安全性

- 厳格な型付けを行って
- 必要に応じて型ガードを使用
- `any`型の使用は原則として避けて
- すべてのコンポーネントの props には型定義を行って
- 外部データの型は`src/types`ディレクトリに定義

### 命名規則

- コンポーネント: `PascalCase.tsx`
- ユーティリティ関数: `camelCase.ts`
- 定数: `UPPER_SNAKE_CASE`
- App Router のページ: `page.tsx`
- ディレクトリ名: `kebab-case`

### コーディングスタイル

- ES2015 以降のモダンな構文を使用
- 関数はアロー関数で作成
  - 推奨: `const sample = (value: string): string => value.toUpperCase();`
  - 非推奨: `function sample(value: string) { return value.toUpperCase(); }`
- コンポーネントは関数コンポーネントとして実装
- 公開関数やコンポーネントには JSDoc を記述
- クラスの代わりに関数を使用

### Next.js 固有のガイドライン

- 可能な限り Server Components を使用
- クライアントでの状態管理が必要な場合のみ`'use client'`ディレクティブを使用
- メタデータは export functions を使って定義
- 画像最適化には必ず`next/image`の`Image`コンポーネントを使用
- リンクには必ず`next/link`の`Link`コンポーネントを使用
- Server Actions はサーバーコンポーネントのみで Form で使用
- Next.js 15 では`params`と`searchParams`が`Promise`型になったため、ページコンポーネントで使用する際は必ず`await`
  - 例: `const resolvedParams = await params`
  - 型定義では`params: Promise<{ id: string }>`、`searchParams: Promise<{ page?: string }>`のように記述

### スタイリング

- `docs-copilot/design-rules/LN_DESIGN_GUIDE.md` のデザインガイドラインに従う
- スタイリングには Tailwind CSS を使用
- カラーは Tailwind のデフォルトカラーパレットを使用
- レスポンシブデザインには Tailwind のブレークポイントを使用

### パフォーマンス最適化

- 不要な再レンダリングを防ぐために`useMemo`と`useCallback`を適切に使用
- 大きなコンポーネントは`React.lazy`と`Suspense`を使用して動的インポート
- クライアントコンポーネントは必要な場合のみに限定

### データ処理とエラーハンドリング

#### Supabase クエリのベストプラクティス

- データの整合性を保つため、不正な値を事前にフィルタリング
- 統計データ取得時は`null`値を適切に除外
- エラーハンドリングは必ず実装し、フォールバック値を提供

```typescript
// 推奨：不正データを除外するクエリ
const { data, error } = await supabase
  .from('table_name')
  .select('columns')
  .not('important_field', 'is', null)
  .order('created_at', { ascending: false });

if (error) {
  console.error('データ取得エラー:', error);
  return fallbackValue; // 必ずフォールバック値を返す
}
```

#### UI の堅牢性

- データの状態に応じた適切な表示分岐を実装
- ローディング状態、エラー状態、空状態を考慮
- データ不整合があっても適切なメッセージを表示

```typescript
// 推奨：3段階の表示ロジック
{
  data?.validField ? (
    <NormalDisplay data={data} />
  ) : data?.partialField ? (
    <PartialDisplay message="データ準備中" />
  ) : (
    <EmptyDisplay message="データがありません" />
  );
}
```

#### 統計データの処理

- 集計処理はフロントエンドで行う際も型安全性を確保
- `Number()`を使用した型変換では`|| 0`でフォールバック値を設定
- 日付計算では`Date`オブジェクトの妥当性を検証

```typescript
// 推奨：安全な統計データ処理
data.forEach((session) => {
  const techRate = Number(session.tech_rate) || 0;
  const attempts = Number(session.total_attempts) || 0;

  if (techRate > 0) {
    // 有効な値のみ処理
    // 統計処理
  }
});
```

## テストガイドライン

### 基本方針

- 基本的なユニットテストを実装
- React Testing Library を使用してコンポーネントをテスト
- 外部サービスの呼び出しはモックを使用

### Supabase のテスト

#### モック化の基本構造

```typescript
// Supabaseクライアントのモック化
jest.mock('@/lib/supabase', () => ({
  supabase: {
    from: jest.fn(),
  },
}));
```

#### クエリチェーンのモック化

Supabase のクエリは`.select().eq().order()`のようにチェーンするため、各メソッドで`mockReturnThis()`を使用：

```typescript
const mockSelect = jest.fn().mockReturnThis();
const mockEq = jest.fn().mockReturnThis();
const mockNot = jest.fn().mockReturnThis();
const mockOrder = jest.fn().mockResolvedValue({
  data: mockData,
  error: null,
});

(supabase.from as jest.Mock).mockReturnValue({
  select: mockSelect,
  eq: mockEq,
  not: mockNot,
  order: mockOrder,
});
```

#### エラーハンドリングのテスト

正常系だけでなく、以下のエラーケースも必ずテスト：

- データが存在しない場合（`data: []`）
- データが null の場合（`data: null`）
- Supabase エラーの場合（`error: { message: 'Database error' }`）
- 例外が発生した場合（`throw new Error()`）

#### 日付処理のテスト

固定日時でのテストには`jest.useFakeTimers()`を使用：

```typescript
beforeEach(() => {
  jest.useFakeTimers();
  jest.setSystemTime(new Date('2024-01-20T12:00:00Z'));
});

afterEach(() => {
  jest.useRealTimers();
});
```

### データ不整合への対応

#### 表示ロジックの堅牢性

データベースに不整合データ（例：`tech_rate`が null のセッション）があっても、UI が適切に動作するよう 3 段階の表示ロジックを実装：

```typescript
// 例：タイムアタック統計の表示
{
  courseStats?.bestTechRate ? (
    // 1. ベストスコアあり → 正常な表示
    <BestScoreDisplay />
  ) : courseStats?.totalSessions > 0 ? (
    // 2. セッションはあるがスコアなし → 「記録更新中」表示
    <InProgressDisplay />
  ) : (
    // 3. セッションなし → 「初回挑戦」表示
    <FirstTimeDisplay />
  );
}
```

#### データフィルタリング

統計関数では不正なデータを事前に除外：

```typescript
// tech_rateがnullでないレコードのみ取得
const { data, error } = await supabase
  .from('time_attack_sessions')
  .select('course, tech_rate, created_at')
  .eq('user_id', userId)
  .not('tech_rate', 'is', null) // 重要：不正データを除外
  .order('created_at', { ascending: false });
```

### テストファイルの命名と配置

- テストファイル：`src/lib/__tests__/ファイル名.test.ts`
- コンポーネントテスト：`src/components/__tests__/コンポーネント名.test.tsx`
- 統合テスト：`src/__tests__/integration/`

## 有料機能・認証に関する情報

プログラミング学習のための有料サブスクリプションコンテンツを提供するウェブサイトを構築します。Next.js と Clerk Billing を組み合わせ、シンプルな認証とサブスクリプション管理を実現します。

### システム構成

- フロントエンド＆バックエンド: Next.js (App Router)
- ホスティング: Vercel
- 認証＆サブスクリプション管理: Clerk Billing

### 基本コンセプト

- シンプルな実装を優先
- 可能な限り外部サービスのデフォルト機能を活用
- TypeScript による型安全性の確保
- 設定ベースのアクセス制御

### 実装における重要な教訓

#### データ不整合への対応

実際の運用では、以下のようなデータ不整合が発生する可能性がある：

- タイムアタック途中での離脱（`tech_rate`が`null`のセッション）
- エラーによる不完全なデータ保存
- 異なるバージョン間でのデータ構造の違い

**対策：**

- データベースクエリレベルでの不正データ除外
- UI 表示ロジックでの多段階フォールバック
- 統計計算での安全な型変換とバリデーション

#### ユーザー体験の向上

```typescript
// ❌ 悪い例：バイナリな表示判定
{
  hasData ? <Content /> : <EmptyState />;
}

// ✅ 良い例：段階的な状態表示
{
  hasValidData ? (
    <FullContent />
  ) : hasPartialData ? (
    <PartialContent message="データ準備中" />
  ) : (
    <EmptyContent message="まだデータがありません" />
  );
}
```
````

</details>

こうして改めて見てみると、かなり膨大ですね。

特に **型安全性** や **エラーハンドリング** などの重要なポイントを明記しておくことで、品質の高いコードが生成されやすくなっていたと思います。

ちょっとデザイン面での反省はあるのですが、それはまた別の機会にお伝えします。

【追記：続編を書きました】

- [【コピペ OK】個人開発で Apple 風デザインルールを作ったら統一感のあるカッコいい UI にできた話](https://qiita.com/tomada/items/decece613046a61b11a3)

## 要件定義・基本設計書の具体例

ここまでで、Claude を使って要件定義と基本設計を行い、Cursor に実装を依頼する流れを説明しました。
次に、実際にどのような設計書を作成したか、具体的な例を示します。

たとえば、以下のような要件定義書・基本設計書を Claude に作成してもらいました。

````markdown
# 練習問題機能 要件定義書・基本設計書

## 要件定義書

### 1. プロジェクト概要

#### 1.1 目的

Learning Next プラットフォームに、ユーザーの理解度を確認するための練習問題機能を追加する。

#### 1.2 背景と目標

- ユーザーが学習した内容を自己評価できる仕組みが必要
- 無料ユーザーにも価値を提供しつつ、有料機能を提供することでさらに学習の深度を増す
- Learning Next プラットフォームの教育効果を高める

#### 1.3 対象ユーザー

- Learning Next プラットフォームの無料ユーザー
- 有料コースを受講中のユーザー（トピック別の学習進捗確認用）

### 2. 機能要件

#### 2.1 練習問題データ管理

- 練習問題はコース（Ruby、Rails、RSpec）ごとに分類
- トピック（配列、ハッシュなど）による細分化
- 難易度（初級、中級、上級）による分類
- 問題文はマークダウン形式で作成（コードブロック含む）
- 4 択問題とテキスト入力問題の 2 種類をサポート

#### 2.2 練習問題選択・フィルタリング機能

- コース、トピック、難易度、ステータス（未回答/不正解/正解済み）でフィルタリング
- 最大問題数の選択（1、3、5、10 問）
- フィルタ結果に基づく問題一覧表示

#### 2.3 練習問題回答機能

- 選択式問題：4 つの選択肢から選択（正解は常に一つ）
- テキスト入力問題：テキスト入力による回答
- 即時フィードバック（正解/不正解の表示）
- 正解の場合：緑色で正解表示
- 不正解の場合：選択肢を赤色表示、正解を緑色表示

#### 2.4 解説・誘導機能

- 関連するコースのレッスンページへのリンク表示
- 別タブでの解説ページ表示

#### 2.5 ユーザー進捗管理

- ユーザーごとの回答履歴の保存
- コース/トピック別の正解率表示
- マイページでの進捗状況確認

#### 2.6 アクセス制御

- 練習問題機能自体は無料ユーザーも利用可能
- 解説ページは有料コンテンツの場合、制限付き表示
- ユーザー進捗管理機能は有料ユーザーのみ利用可能

---

## 基本設計書

### 1. システム構成

#### 1.1 全体構成

- Learning Next プラットフォーム（Next.js）：練習問題 UI 表示、回答処理
- 練習問題データ管理システム（Python）：問題データの管理とインポート
- Supabase：データベース（ユーザー回答履歴、練習問題データの格納）

#### 1.2 インフラストラクチャ

```

+-----------------------+ +------------------+
| Learning Next | | 練習問題データ管理 |
| (Next.js) | | (Python) |
+-----------------------+ +------------------+
| |
| API | インポート
v v
+-----------------------+ +------------------+
| Supabase | | 練習問題データファイル|
| (PostgreSQL) | <---- | (分離ファイル方式) |
+-----------------------+ +------------------+

```

### 2. データ設計

#### 2.1 練習問題データファイル構造

...以下略
````

ざっくりと「こういうことをやりたい」と伝えただけで、Claude が要件定義と基本設計書を作成してくれます。

この時に重要なのは、「既存の実装や設計も簡潔に伝えること」「データベースの構造や API の設計も含めて具体的に伝えること」です。

たとえば私の場合は、以下のような情報も適宜 Claude に提供しました。

````markdown
# 参考

## テーブル構造

関連するテーブルの構造は以下の通りです。

```sql
...
```

## 既存のコンポーネント

「xxx」という機能においては、たとえば以下のようなコンポーネントが既に実装されています。
基本設計の際には、これらのコンポーネントを参考にしてください。

```tsx
// ...
```
````

これがないと、既存の実装に全く合っていない設計を作成されてしまう可能性があります。

それを Cursor に渡しても、実装がうまくいかないことが多いので、整合性を保つように参考情報はしっかりと提供することが重要です。

## 1 ヶ月で完成させるためのタイムライン

最後に、1 ヶ月という短期間で完成させるためにどのフェーズで、どの機能を実装したか、タイムラインを示します。

最初に基礎的な部分を固めてから、徐々に機能を追加していく形で進めました。

参考までに、1 ヶ月間の開発の流れとタイムラインをご紹介します。

### 1 週目：基盤構築

まずは基盤を固めることから始めました。
すべての機能に共通する部分を最初に実装することで、後の開発がスムーズになります。

- プロジェクト初期設定
- 認証機能（Clerk 連携）
- データベース設計（Supabase）
- 共通コンポーネント

### 2 週目：コア機能実装

次に、当サイトにとって重要なコア機能を実装しました。

中心的な機能であるという理由もある一方、他機能と連携している部分も多いため、早めに固めておくことにした感じです。

- 学習コンテンツ表示
- 練習問題の基本機能
- ユーザー回答履歴管理
- ユーザーダッシュボード

### 3 週目：応用機能実装

コア機能が完成したら、次は応用的な機能を追加しました。

イメージとしては「なくても動くけど、あったら便利な機能」を中心に実装しました。

- ランキング機能
- タイムアタック機能
- 統計・分析機能

### 4 週目：最適化・デバッグ

最後に、全体の最適化やバグ修正を行いました。

- コードリファクタリング（コンポーネント分割など）
- パフォーマンス改善
- UI/UX 調整
- バグ修正

ただ、リファクタリングやパフォーマンス改善は先にやっておくべきだったと反省しています。

なぜならば、後からやると **修正箇所が多くなり、手戻りが発生しやすい** からです。

詳細は後述します。

## 今回の開発で感じた AI 駆動開発の可能性

1 ヶ月間、AI と一緒に開発を進めてみて、**本当に開発効率が変わった** と実感しています。

今更ながら、今回作ったサービスは、以前作った個人開発アプリを「学習特化型」にしたものです。

※当時のノウハウ、特に SEO 観点を記事にしているのでよかったら読んでみてください。

- [参考：【個人開発】リリース 1 ヶ月で月 5 万円(理論値)のサービスを作ったのでノウハウを全公開してみる（Next.js / Rails）](https://qiita.com/tomada/items/b992245a4162ddeb1f6e)

話を戻しますと、以前も AI エージェントを使って開発をしていましたが、今回のように **要件定義から実装、ドキュメント化まで一貫して AI を本気で活用した開発** は初めてでした。

特に、機能のドキュメント化を行うことで機能の追加・修正が非常にスムーズになったので、皆さんにもおすすめしたいポイントです

## 実際に開発してみて感じた課題と反省点

AI 駆動開発で確かに効率は上がったんですが、実際にやってみると **「あー、これは失敗だったな」** と思うポイントもいくつかありました。

それは Cursor などが悪いというわけではなく「こうすればよかったな」と言うポイントなので、これから AI 駆動開発に挑戦する方の参考になればと思います。

### Shadcn/ui は個人的には合わなかった

最初は **Shadcn/ui** を使ってモダンな UI を作ろうと思ったんですが、これが想定以上に大変でした。

Cursor が生成するコードだと、**存在しないオプションを使おうとしたり、必要な props が足りていない** ことが頻繁にあったからです。

例えば、Button コンポーネントで存在しないバリアントを指定してきたり、必要な props が抜けていたりといった具合です。

結果的に、**人力での調整に時間を取られる** ことが多くなってしまい、途中でうんざりしてしまいました。

そのため途中から **Tailwind CSS のみ** で UI を作ることに切り替えました。

見た目は Cursor に頑張ってもらう必要はあるものの、シンプルなので Cursor との相性は断然良かったです。

決して Shadcn が悪いというわけではなく、予め Shadcn に関するドキュメントをしっかりと整備しておけば、Cursor も間違えにくかったかもしれません。

### モック（UI のイメージ）を最初に作るべきだった

これは完全に反省点なんですが、**機能開発と UI 作成を同時並行** でやってしまったんです。

「動く機能を作りながら見た目も整えていこう」という考えだったんですが、これが **めちゃくちゃ大変** でした。

本来なら、最初に **静的なモック（デザインだけのページ）** を作ってから機能開発に入るべきでした。

実際の業務でも、UI デザイナーが先にモックを作ってから開発に入ることが多いと思います。

また、画面も一旦は静的な（HTML と CSS だけの）ページを作っておいて、そこに Cursor で機能を追加していく形にすれば、**Cursor が「どの部分に機能を追加すれば良いか」を明確に理解できた** はずです。

今回は **「とりあえず動くものを作る」** という方針で進めてしまったため、後から UI の調整が大変でした。

特に、UI 調整にともなってコンポーネントを組み替えたり、大幅なリファクタリングが必要になったりして、Cursor が生成したコードが **「壊れやすい」** という問題も発生しました。

そのため、

### デザインガイドを最初に作っておきべきだった

これも大きな反省点です。

最初は **「とりあえず動くものを作ろう」** という勢いで進めたんですが、途中で色の統一感がなかったり、余白がバラバラだったりして、**後から調整するのが大変** でした。

AI 駆動開発だと、どんどん機能やコンポーネントを追加していくのが基本なので、全体としてデザインの統一感を保つのが難しいのだと思います。

そのため、最初に **デザインガイドライン** を作成しておくべきでした。

趣味の個人開発だとデザインガイドラインまで策定することは少ないかと思いますが、たとえば以下のような内容をドキュメント化しておくと良いでしょう。

- **配色ルール**（プライマリカラー、セカンダリカラーなど）
- **余白の基準**（8px 単位とか 16px 単位とか）
- **フォントサイズの階層**
- **ボタンやカードの基本デザイン**
- **アイコンの使用ルール**（Lucide Icons や Devicon の使い方）

途中で気づいて、Claude に **「サイトのイメージ」と「Apple 風のデザイン」をデザインルールとしてドキュメント化** してもらったんですが、これをやったら一気に統一感が出ました。

このデザインガイド作成については、また別の記事で詳しく書こうと思います。

### コンポーネントは細かく分割すべき

これは AI エージェント特有の技術的な反省点です。

最初は **「Cursor に渡すファイル数を少なくしよう」** と思って、1 つのコンポーネントを大きめに作っていたんです。

そうすることで、Cursor に渡すファイルが少なくなり、AI が理解しやすいかなと思ったからです。

でも、これが裏目に出ました。

修正途中で **ファイルが壊れて、何度もゼロから作り直し** という事態になったんです。特に複雑な状態管理が絡むコンポーネントで頻発しました。

おそらく、Cursor が大きな実装を一度に情報として保持しきれず、途中で情報が失われてしまったのだと思います。

そのため、早めに **コンポーネントを細かく分割** して、1 つのファイルの責任を小さくするべきだったと思います。

ただし、分割した時は **コンポーネント構成のドキュメント** を作らせることが重要ですね。

「このコンポーネントは何をするのか」「どのコンポーネントと連携するのか」を軽くでもドキュメント化しておくと、後の修正がスムーズになります。

### 結論： AI 駆動開発は「適当じゃダメ」

AI を使った開発は確かに効率的ですが、**従来の開発とは違ったコツ** が必要だなと感じました。

特に **「AI が間違えにくい環境を整える」** ことや、**「機能を追加・修正しやすい準備をしておく」** ことが重要です。

今回の経験を踏まえて、次回はもっとスムーズに開発できそうです。

## おわりに

今回、Cursor と Claude を使った AI 駆動開発で、**1 ヶ月という短期間** で本格的な学習プラットフォームを作ることができました。

改めて振り返ると、以下のポイントが成功の鍵だと感じています。

- **要件定義と基本設計をしっかり行う**
- **ドキュメント化を徹底する**
- **AI エージェントに適切な情報を提供する**
- **コンポーネントを細かく分割し、責任を明確にする**
- **デザインガイドラインを最初に作成する**
- **AI エージェントとのコミュニケーションを密にする**
- **実装後のドキュメント化を忘れない**
- **`.cursorrules` を適切に設定する**

AI 駆動開発は、もはや **「試してみる」段階** を超えて、**「実用的に活用する」段階** に来ています。

実際、私の本業の方でもコードはほぼ AI エージェントに書かせていて、AI エージェントが生成したコードを人間がレビューする形で開発を進めています。

ぜひ皆さんも、今回紹介したノウハウを参考に、AI と一緒に開発してみてください！

そして、もしプログラミング学習に興味がある方は、今回作った **[Learning Next](https://school.learning-next.app/)** を試していただけると嬉しいです！！

特に **練習問題機能** は、1,000 問以上を用意していて、Ruby、Rails、JavaScript、React などの実力アップに役立つと思います！

この記事が、AI 駆動開発に挑戦する方の参考になれば幸いです。

質問やフィードバックがあれば、ぜひコメントで教えてください！

その他、[X の DM](https://x.com/muscle_coding) でも受け付けていますので、お気軽にご連絡いただけると嬉しいです。