【入門】Nest.js×Next.jsでCRUD機能作ってみる【frontend】

backendを`nest.js`で作成したので、frontendを`next.js`で作成してみる
backendの作成記事は[こちら](https://qiita.com/okaki1999/items/352ee2b739ff55dcad03)

## frontendの作成
### プロジェクトの作成
```
npx create-next-app@latest task-frontend --typescript
```
今回はプロンプトはすべて`Enter`でOK

### port設定
今回backendを`3000`で起動しているので、frontendは`3001`で起動するように編集

```js:package.json
"scripts": {
  "dev": "next dev -p 3001",
  ...
}
```
この設定により`npm run dev`で毎回`3001`で起動します。

### CORS設定（バックエンドで許可必要）
NestJS 側でフロント（localhost:3000）からのアクセスを許可するよう、`main.ts` に以下を追加
```ts:main.ts
// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors(); // ← これが必要！
  await app.listen(3000);
}
```
backendを再起動してください

### UI画面の編集
```ts:app/page.tsx
'use client';

import { useEffect, useState } from 'react';

type Task = {
  id: number;
  title: string;
  description?: string;
};

export default function Home() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [editingId, setEditingId] = useState<number | null>(null);
  const [editTitle, setEditTitle] = useState('');
  const [editDescription, setEditDescription] = useState('');

  const fetchTasks = async () => {
    const res = await fetch('http://localhost:3000/tasks');
    const data = await res.json();
    setTasks(data);
  };

  useEffect(() => {
    fetchTasks();
  }, []);

  const handleCreate = async () => {
    await fetch('http://localhost:3000/tasks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, description }),
    });
    setTitle('');
    setDescription('');
    fetchTasks();
  };

  const handleDelete = async (id: number) => {
    await fetch(`http://localhost:3000/tasks/${id}`, {
      method: 'DELETE',
    });
    fetchTasks();
  };

  const startEdit = (task: Task) => {
    setEditingId(task.id);
    setEditTitle(task.title);
    setEditDescription(task.description || '');
  };

  const handleUpdate = async () => {
    if (editingId === null) return;
    await fetch(`http://localhost:3000/tasks/${editingId}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: editTitle, description: editDescription }),
    });
    setEditingId(null);
    setEditTitle('');
    setEditDescription('');
    fetchTasks();
  };

  return (
    <main className="min-h-screen bg-gray-100 p-8">
      <div className="max-w-2xl mx-auto bg-white shadow-lg rounded-lg p-6">
        <h1 className="text-2xl font-bold mb-6 text-center text-gray-800">TODOリスト</h1>

        <div className="flex flex-col md:flex-row gap-2 mb-6">
          <input
            value={title}
            onChange={e => setTitle(e.target.value)}
            placeholder="Title"
            className="border p-2 flex-1 rounded text-gray-800"
          />
          <input
            value={description}
            onChange={e => setDescription(e.target.value)}
            placeholder="Description"
            className="border p-2 flex-1 rounded text-gray-800"
          />
          <button
            onClick={handleCreate}
            className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
          >
            追加
          </button>
        </div>

        <ul className="space-y-4">
          {tasks.map(task => (
            <li
              key={task.id}
              className="bg-gray-50 p-4 border rounded flex flex-col md:flex-row md:items-center justify-between"
            >
              {editingId === task.id ? (
                <div className="flex flex-col md:flex-row md:items-center gap-2 w-full">
                <input
                  value={editTitle}
                  onChange={e => setEditTitle(e.target.value)}
                  className="border p-2 rounded flex-1 text-gray-800"
                  placeholder="タイトル"
                />
                <input
                  value={editDescription}
                  onChange={e => setEditDescription(e.target.value)}
                  className="border p-2 rounded flex-1 text-gray-800"
                  placeholder="詳細"
                />
                <div className="flex gap-2">
                  <button
                    onClick={handleUpdate}
                    className="bg-green-500 hover:bg-green-600 text-white px-3 py-2 rounded min-w-[64px]"
                  >
                    保存
                  </button>
                  <button
                    onClick={() => setEditingId(null)}
                    className="bg-gray-300 hover:bg-gray-400 px-3 py-2 rounded min-w-[64px]"
                  >
                    キャンセル
                  </button>
                </div>
                </div>
              ) : (
                <>
                  <div>
                    <h3 className="font-semibold text-lg text-gray-800">{task.title}</h3>
                    <p className="text-gray-600 text-sm">{task.description}</p>
                  </div>
                  <div className="flex gap-2 mt-4 md:mt-0">
                    <button
                      onClick={() => startEdit(task)}
                      className="bg-yellow-400 hover:bg-yellow-500 text-white px-3 py-1 rounded"
                    >
                      編集
                    </button>
                    <button
                      onClick={() => handleDelete(task.id)}
                      className="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded"
                    >
                      削除
                    </button>
                  </div>
                </>
              )}
            </li>
          ))}
        </ul>
      </div>
    </main>
  );
}
```

### 動作確認
![スクリーンショット 2025-06-14 15.11.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801775/68c96686-a476-473f-94e9-295e64c930e0.png)

![スクリーンショット 2025-06-14 15.11.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801775/8cc0b6f2-031e-4a04-aa61-0445ce852d0a.png)

登録、編集、削除問題なく動いていそうですね。

## バリデーション
このままでも良いのですが、おまけとして未入力の時のバリデーションを設定しましょう


```ts:app/page.tsx
//状態を追加
const [createError, setCreateError] = useState('');
const [editError, setEditError] = useState('');

//登録処理
const handleCreate = async () => {
  if (!title.trim()) {
    setCreateError('タイトルは必須です');
    return;
  }
  setCreateError('');

  await fetch('http://localhost:3000/tasks', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, description }),
  });
  setTitle('');
  setDescription('');
  fetchTasks();
};

//編集処理
const handleUpdate = async () => {
  if (!editTitle.trim()) {
    setEditError('タイトルは必須です');
    return;
  }
  setEditError('');

  await fetch(`http://localhost:3000/tasks/${editingId}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title: editTitle, description: editDescription }),
  });
  setEditingId(null);
  setEditTitle('');
  setEditDescription('');
  fetchTasks();
};
```
登録フォーム下に追加
```ts:app/page.tsx
{createError && (
  <p className="text-red-500 text-sm mt-1">{createError}</p>
)}
```
編集フォーム下に追加
```ts:app/page.tsx
{editError && (
  <p className="text-red-500 text-sm mt-1">{editError}</p>
)}
```
### 動作確認
![スクリーンショット 2025-06-14 15.34.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801775/b0556b1f-28dd-4fca-8a88-2b02c41ac633.png)

動いていますね

### 最終UIコード
```ts:app/page.tsx
'use client';

import { useEffect, useState } from 'react';

type Task = {
  id: number;
  title: string;
  description?: string;
};

export default function Home() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [editingId, setEditingId] = useState<number | null>(null);
  const [editTitle, setEditTitle] = useState('');
  const [editDescription, setEditDescription] = useState('');
  const [createError, setCreateError] = useState('');
  const [editError, setEditError] = useState('');
  
  const fetchTasks = async () => {
    const res = await fetch('http://localhost:3000/tasks');
    const data = await res.json();
    setTasks(data);
  };

  useEffect(() => {
    fetchTasks();
  }, []); 

  const handleCreate = async () => {
    if (!title.trim()) {
      setCreateError('タイトルは必須です');
      return;
    }
    setCreateError('');
  
    await fetch('http://localhost:3000/tasks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, description }),
    });
    setTitle('');
    setDescription('');
    fetchTasks();
  };

  const handleUpdate = async () => {
    if (!editTitle.trim()) {
      setEditError('タイトルは必須です');
      return;
    }
    setEditError('');
  
    await fetch(`http://localhost:3000/tasks/${editingId}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: editTitle, description: editDescription }),
    });
    setEditingId(null);
    setEditTitle('');
    setEditDescription('');
    fetchTasks();
  };

  const handleCancel = () => {
    setEditingId(null);
    setEditTitle('');
    setEditDescription('');
    setEditError('');
  };

  const handleDelete = async (id: number) => {
    await fetch(`http://localhost:3000/tasks/${id}`, {
      method: 'DELETE',
    });
    fetchTasks();
  };

  const startEdit = (task: Task) => {
    setEditingId(task.id);
    setEditTitle(task.title);
    setEditDescription(task.description || '');
  };

  return (
    <main className="min-h-screen bg-gray-100 p-8">
      <div className="max-w-2xl mx-auto bg-white shadow-lg rounded-lg p-6">
        <h1 className="text-2xl font-bold mb-6 text-center text-gray-800">TODOリスト</h1>
          {createError && (
            <p className="text-red-500 text-sm mt-1">{createError}</p>
          )}
        <div className="flex flex-col md:flex-row gap-2 mb-6">
          <input
            value={title}
            onChange={e => setTitle(e.target.value)}
            placeholder="Title"
            className="border p-2 flex-1 rounded text-gray-800"
          />
          <input
            value={description}
            onChange={e => setDescription(e.target.value)}
            placeholder="Description"
            className="border p-2 flex-1 rounded text-gray-800"
          />
          <button
            onClick={handleCreate}
            className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
          >
            追加
          </button>
        </div>

        <ul className="space-y-4">
          {tasks.map(task => (
            
            <li
              key={task.id}
              className="bg-gray-50 p-4 border rounded flex flex-col md:flex-row md:items-center justify-between"
            >
              {editingId === task.id ? (
                <div>
                  {editError && (
                    <p className="text-red-500 text-sm">{editError}</p>
                  )}
                  <div className="flex flex-col md:flex-row md:items-center gap-2 w-full">
                  <input
                    value={editTitle}
                    onChange={e => setEditTitle(e.target.value)}
                    className="border p-2 rounded flex-1 text-gray-800"
                    placeholder="タイトル"
                  />
                  <input
                    value={editDescription}
                    onChange={e => setEditDescription(e.target.value)}
                    className="border p-2 rounded flex-1 text-gray-800"
                    placeholder="詳細"
                  />
                  <div className="flex gap-2">
                    <button
                      onClick={handleUpdate}
                      className="bg-green-500 hover:bg-green-600 text-white px-3 py-2 rounded min-w-[64px]"
                    >
                      保存
                    </button>
                    <button
                      onClick={handleCancel}
                      className="bg-gray-300 hover:bg-gray-400 px-3 py-2 rounded min-w-[64px]"
                    >
                      キャンセル
                    </button>
                  </div>
                  </div>
                </div>
              ) : (
                <>
                  <div>
                    <h3 className="font-semibold text-lg text-gray-800">{task.title}</h3>
                    <p className="text-gray-600 text-sm">{task.description}</p>
                  </div>
                  <div className="flex gap-2 mt-4 md:mt-0">
                    <button
                      onClick={() => startEdit(task)}
                      className="bg-yellow-400 hover:bg-yellow-500 text-white px-3 py-1 rounded"
                    >
                      編集
                    </button>
                    <button
                      onClick={() => handleDelete(task.id)}
                      className="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded"
                    >
                      削除
                    </button>
                  </div>
                </>
              )}
            </li>
          ))}
        </ul>
      </div>
    </main>
  );
}
```

読んでくださりありがとうございました。