0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-06-14

backendをnest.jsで作成したので、frontendをnext.jsで作成してみる
backendの作成記事はこちら

frontendの作成

プロジェクトの作成

npx create-next-app@latest task-frontend --typescript

今回はプロンプトはすべてEnterでOK

port設定

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

package.json
"scripts": {
  "dev": "next dev -p 3001",
  ...
}

この設定によりnpm run devで毎回3001で起動します。

CORS設定(バックエンドで許可必要)

NestJS 側でフロント(localhost:3000)からのアクセスを許可するよう、main.ts に以下を追加

main.ts
// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors(); // ← これが必要!
  await app.listen(3000);
}

backendを再起動してください

UI画面の編集

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

スクリーンショット 2025-06-14 15.11.32.png

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

バリデーション

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

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();
};

登録フォーム下に追加

app/page.tsx
{createError && (
  <p className="text-red-500 text-sm mt-1">{createError}</p>
)}

編集フォーム下に追加

app/page.tsx
{editError && (
  <p className="text-red-500 text-sm mt-1">{editError}</p>
)}

動作確認

スクリーンショット 2025-06-14 15.34.35.png

動いていますね

最終UIコード

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>
  );
}

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

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?