【Next.js】簡単なToDoアプリの作り方を解説|初心者がアプリ作成をしながらCRUD機能を学ぶのにおすすめ

Next.jsで簡単なToDoアプリを作成する手順を解説します。Next.jsのAPI Routesで、データの追加・読み取り・更新・削除(CRUD)を行うAPIを作成する方法を学びます。また、ReactのuseStateを使い、フロントエンドからそのAPIと通信してToDoを操作する実装も体験でき、Web開発の基本を一通り学べる内容です。

作成日: 更新日:

開発環境

  • OS: Windows10
  • Visual Studio Code: 1.73.0
  • node: 22.14.0
  • react: 19.0.0
  • next: 15.3.2
  • tailwindcss: 4

サンプルコード

/src/app/api/todos/route.tsx

/src/app/api/todos/route.tsx
1+import { NextResponse } from 'next/server'
2+
3+let todos: string[] = []
4+
5+export async function GET() {
6+  return NextResponse.json(todos)
7+}
8+
9+export async function POST(req: Request) {
10+  const { todo } = await req.json()
11+  if (!todo || typeof todo !== 'string') {
12+    return NextResponse.json({ error: 'Invalid todo' }, { status: 400 })
13+  }
14+  todos.push(todo)
15+  return NextResponse.json(todos)
16+}
17+
18+export async function DELETE(req: Request) {
19+  const { index } = await req.json()
20+  if (typeof index !== 'number' || index < 0 || index >= todos.length) {
21+    return NextResponse.json({ error: 'Invalid index' }, { status: 400 })
22+  }
23+  todos.splice(index, 1)
24+  return NextResponse.json(todos)
25+}
26+
27+export async function PATCH(req: Request) {
28+  const { index, newTodo } = await req.json()
29+  if (
30+    typeof index !== 'number' ||
31+    index < 0 ||
32+    index >= todos.length ||
33+    typeof newTodo !== 'string'
34+  ) {
35+    return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
36+  }
37+  todos[index] = newTodo
38+  return NextResponse.json(todos)
39+}
40

/src/app/page.tsx

/src/app/page.tsx
1+'use client'
2+
3+import { useEffect, useState } from 'react'
4+
5 export default function Home() {
6+  const [todos, setTodos] = useState<string[]>([])
7+  const [input, setInput] = useState('')
8+  const [editIndex, setEditIndex] = useState<number | null>(null)
9+  const [editText, setEditText] = useState('')
10+
11+  const fetchTodos = async () => {
12+    const res = await fetch('/api/todos')
13+    const data = await res.json()
14+    setTodos(data)
15+  }
16+
17+  const handleAdd = async () => {
18+    if (!input.trim()) return
19+    await fetch('/api/todos', {
20+      method: 'POST',
21+      headers: { 'Content-Type': 'application/json' },
22+      body: JSON.stringify({ todo: input }),
23+    })
24+    setInput('')
25+    fetchTodos()
26+  }
27+
28+  const handleDelete = async (index: number) => {
29+    await fetch('/api/todos', {
30+      method: 'DELETE',
31+      headers: { 'Content-Type': 'application/json' },
32+      body: JSON.stringify({ index }),
33+    })
34+    fetchTodos()
35+  }
36+
37+  const handleEdit = (index: number) => {
38+    setEditIndex(index)
39+    setEditText(todos[index])
40+  }
41+
42+  const handleUpdate = async () => {
43+    if (editIndex === null || !editText.trim()) return
44+    await fetch('/api/todos', {
45+      method: 'PATCH',
46+      headers: { 'Content-Type': 'application/json' },
47+      body: JSON.stringify({ index: editIndex, newTodo: editText }),
48+    })
49+    setEditIndex(null)
50+    setEditText('')
51+    fetchTodos()
52+  }
53+
54+  useEffect(() => {
55+    fetchTodos()
56+  }, [])
57+
58   return (
59-    <main className="flex min-h-screen items-center justify-center bg-gray-100">
60-      <h1 className="text-4xl font-bold text-blue-600">
61-        Welcome to My Todo App!
62-      </h1>
63+    <main className="min-h-screen bg-gray-100 p-6">
64+      <div className="max-w-md mx-auto bg-white rounded-xl shadow-md p-6">
65+        <h1 className="text-2xl font-bold mb-4 text-center text-blue-600">My Todo App</h1>
66+
67+        {/* 新規追加フォーム */}
68+        <div className="flex gap-2 mb-4">
69+          <input
70+            type="text"
71+            value={input}
72+            onChange={(e) => setInput(e.target.value)}
73+            className="flex-1 border border-gray-300 rounded px-3 py-2"
74+            placeholder="Add new todo"
75+          />
76+          <button
77+            onClick={handleAdd}
78+            className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
79+          >
80+            Add
81+          </button>
82+        </div>
83+
84+        {/* Todoリスト */}
85+        <ul className="space-y-2">
86+          {todos.map((todo, index) => (
87+            <li
88+              key={index}
89+              className="flex items-center justify-between bg-gray-50 p-2 rounded"
90+            >
91+              <div className="flex-1">
92+                {editIndex === index ? (
93+                  <input
94+                    type="text"
95+                    value={editText}
96+                    onChange={(e) => setEditText(e.target.value)}
97+                    className="w-full border-b border-blue-400 px-1 focus:outline-none"
98+                  />
99+                ) : (
100+                  <span>{todo}</span>
101+                )}
102+              </div>
103+
104+              <div className="flex gap-2 ml-2">
105+                {editIndex === index ? (
106+                  <button
107+                    onClick={handleUpdate}
108+                    className="text-green-600 hover:underline text-sm"
109+                  >
110+                    更新
111+                  </button>
112+                ) : (
113+                  <button
114+                    onClick={() => handleEdit(index)}
115+                    className="text-blue-600 hover:underline text-sm"
116+                  >
117+                    編集
118+                  </button>
119+                )}
120+
121+                <button
122+                  onClick={() => handleDelete(index)}
123+                  className="text-red-500 hover:underline text-sm"
124+                >
125+                  削除
126+                </button>
127+              </div>
128+            </li>
129+          ))}
130+        </ul>
131+      </div>
132     </main>
133-  );
134-}
135+  )
136+}
137

コード解説

変更点: APIのバックエンド処理の準備

/src/app/api/todos/route.tsx
1+import { NextResponse } from 'next/server'
2+
3+let todos: string[] = []

このコードは、ToDoアプリのデータ操作を担当するAPIの準備をしています。 import { NextResponse } from 'next/server'は、Next.jsでAPIの応答(レスポンス)を生成するために必要なNextResponseという機能を読み込んでいます。これを使うことで、フロントエンドにJSON形式のデータを返せるようになります。 let todos: string[] = []は、追加されたToDoを保存するための配列を定義しています。本来はデータベースに保存しますが、今回は学習用に、サーバーのメモリ上に一時的にデータを保持する簡単な方法をとっています。string[]は、この配列には文字列(ToDoの内容)だけが入ることを示しています。

変更点: ToDo一覧を取得するAPI(GET)の実装

/src/app/api/todos/route.tsx
1+export async function GET() {
2+  return NextResponse.json(todos)
3+}

ここでは、保存されている全てのToDoを取得するためのAPIを実装しています。 export async function GET()は、HTTPのGETリクエストが/api/todosというURLに来たときに実行される関数です。 return NextResponse.json(todos)は、todos配列に保存されている現在の全てのToDoを、JSON形式でフロントエンドに返却する処理です。これにより、フロントエンドは最新のToDoリストを表示できます。

変更点: ToDoを追加するAPI(POST)の実装

/src/app/api/todos/route.tsx
1+export async function POST(req: Request) {
2+  const { todo } = await req.json()
3+  if (!todo || typeof todo !== 'string') {
4+    return NextResponse.json({ error: 'Invalid todo' }, { status: 400 })
5+  }
6+  todos.push(todo)
7+  return NextResponse.json(todos)
8+}

ここでは、新しいToDoを追加するためのAPIを実装しています。 export async function POST(req: Request)は、HTTPのPOSTリクエストが来たときに実行される関数です。引数のreqには、フロントエンドから送られてきたリクエスト情報が含まれています。 const { todo } = await req.json()は、リクエストの本体(ボディ)からJSONデータを取り出し、その中にあるtodoというキーの値を取得しています。 if (!todo || typeof todo !== 'string')は、受け取ったtodoが空であったり、文字列でなかったりした場合にエラーを返すためのチェックです。 todos.push(todo)は、チェックを通過した新しいToDoをtodos配列の末尾に追加します。 最後にreturn NextResponse.json(todos)で、更新後のToDoリスト全体をフロントエンドに返しています。

変更点: ToDoを削除するAPI(DELETE)の実装

/src/app/api/todos/route.tsx
1+export async function DELETE(req: Request) {
2+  const { index } = await req.json()
3+  if (typeof index !== 'number' || index < 0 || index >= todos.length) {
4+    return NextResponse.json({ error: 'Invalid index' }, { status: 400 })
5+  }
6+  todos.splice(index, 1)
7+  return NextResponse.json(todos)
8+}

ここでは、指定されたToDoを削除するためのAPIを実装しています。 export async function DELETE(req: Request)は、HTTPのDELETEリクエストが来たときに実行される関数です。 const { index } = await req.json()で、リクエストのボディから削除したいToDoの場所(インデックス番号)を受け取ります。 if (...)の部分では、受け取ったindexが正しい数値であるか、またtodos配列の範囲内にあるかをチェックしています。無効な場合はエラーを返します。 todos.splice(index, 1)は、配列のspliceメソッドを使い、指定されたindexの場所から1つだけ要素を削除する処理です。 最後に、削除後のToDoリスト全体をJSON形式で返します。

変更点: ToDoを更新するAPI(PATCH)の実装

/src/app/api/todos/route.tsx
1+export async function PATCH(req: Request) {
2+  const { index, newTodo } = await req.json()
3+  if (
4+    typeof index !== 'number' ||
5+    index < 0 ||
6+    index >= todos.length ||
7+    typeof newTodo !== 'string'
8+  ) {
9+    return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
10+  }
11+  todos[index] = newTodo
12+  return NextResponse.json(todos)
13+}

ここでは、既存のToDoを新しい内容に更新するためのAPIを実装しています。 export async function PATCH(req: Request)は、HTTPのPATCHリクエストが来たときに実行される関数です。部分的な更新にはPATCHがよく使われます。 const { index, newTodo } = await req.json()で、リクエストボディから更新対象のindexと新しいToDoの内容newTodoを受け取ります。 if (...)の部分では、indexnewTodoが有効な値であるかをチェックしています。 todos[index] = newTodoは、todos配列の指定されたindexの要素を、新しいnewTodoの内容で上書きします。 最後に、更新後のToDoリスト全体をJSON形式で返します。

変更点: フロントエンドでReactの機能を使う準備

/src/app/page.tsx
1+'use client'
2+
3+import { useEffect, useState } from 'react'

このコードは、画面(UI)を構築するフロントエンド側の準備です。 'use client'は、このファイルが「クライアントコンポーネント」であることをNext.jsに伝える宣言です。これにより、ブラウザ上でインタラクティブに動作するコンポーネントとなり、ユーザーの操作に応じて画面を動的に変更できるようになります。 import { useEffect, useState } from 'react'は、Reactの「フック」という機能を読み込んでいます。

  • useState: コンポーネントの状態(データ)を管理するために使います。例えば、ToDoリストや入力欄の文字列などを保持します。
  • useEffect: 特定のタイミングで処理を実行するために使います。例えば、ページが最初に表示されたときにAPIからデータを取得する、といった処理を記述します。

変更点: アプリケーションの状態管理変数の定義

/src/app/page.tsx
1 export default function Home() {
2+  const [todos, setTodos] = useState<string[]>([])
3+  const [input, setInput] = useState('')
4+  const [editIndex, setEditIndex] = useState<number | null>(null)
5+  const [editText, setEditText] = useState('')

ここでは、useStateフックを使って、アプリケーションが必要とする様々な「状態(state)」を管理するための変数を定義しています。

  • const [todos, setTodos] = useState<string[]>([]): ToDoリストを保持するための状態です。todosが現在のリストデータ、setTodosがそれを更新するための関数です。初期値は空の配列[]です。
  • const [input, setInput] = useState(''): 新規ToDoを追加する入力欄のテキストを管理する状態です。初期値は空文字列''です。
  • const [editIndex, setEditIndex] = useState<number | null>(null): どのToDoを編集中かを示すインデックス番号を管理します。誰も編集していない場合はnullになります。
  • const [editText, setEditText] = useState(''): 編集中のToDoのテキスト内容を管理する状態です。

変更点: APIからToDoリストを取得する関数の実装

/src/app/page.tsx
1+  const fetchTodos = async () => {
2+    const res = await fetch('/api/todos')
3+    const data = await res.json()
4+    setTodos(data)
5+  }

このfetchTodos関数は、先ほど作成したバックエンドのAPIにリクエストを送り、ToDoリストを取得して画面に反映させるためのものです。 const res = await fetch('/api/todos')は、/api/todosエンドポイントに対してGETリクエストを送信し、サーバーからの応答をresに格納します。fetchはブラウザが標準で持っている、サーバーと通信するための機能です。 const data = await res.json()は、受け取った応答をJSON形式からJavaScriptのオブジェクト(この場合は配列)に変換します。 setTodos(data)は、取得したToDoリストのデータを使ってtodosの状態を更新します。これにより、Reactが画面の表示を自動的に最新の状態に更新してくれます。

変更点: ToDoを追加する処理の実装

/src/app/page.tsx
1+  const handleAdd = async () => {
2+    if (!input.trim()) return
3+    await fetch('/api/todos', {
4+      method: 'POST',
5+      headers: { 'Content-Type': 'application/json' },
6+      body: JSON.stringify({ todo: input }),
7+    })
8+    setInput('')
9+    fetchTodos()
10+  }

このhandleAdd関数は、「Add」ボタンがクリックされたときに新しいToDoを追加する処理です。 if (!input.trim()) returnは、入力欄が空、またはスペースのみの場合は処理を中断します。 await fetch('/api/todos', { ... })で、/api/todosにPOSTリクエストを送信します。

  • method: 'POST': 通信方法がPOSTであることを指定します。
  • headers: { 'Content-Type': 'application/json' }: 送信するデータの形式がJSONであることをサーバーに伝えます。
  • body: JSON.stringify({ todo: input }): input状態(入力欄のテキスト)を{ todo: ... }という形のJavaScriptオブジェクトにし、JSON.stringifyでJSON形式の文字列に変換してサーバーに送信します。 setInput('')で、追加後に入力欄を空にします。 fetchTodos()を呼び出すことで、サーバーから最新のToDoリストを再取得し、画面表示を更新します。

変更点: ToDoを削除する処理の実装

/src/app/page.tsx
1+  const handleDelete = async (index: number) => {
2+    await fetch('/api/todos', {
3+      method: 'DELETE',
4+      headers: { 'Content-Type': 'application/json' },
5+      body: JSON.stringify({ index }),
6+    })
7+    fetchTodos()
8+  }

このhandleDelete関数は、「削除」ボタンがクリックされたときに特定のToDoを削除する処理です。引数indexで削除対象のToDoの番号を受け取ります。 await fetch('/api/todos', { ... })で、/api/todosにDELETEリクエストを送信します。

  • method: 'DELETE': 通信方法がDELETEであることを指定します。
  • body: JSON.stringify({ index }): 削除したいToDoのインデックス番号をJSON形式でサーバーに送信します。 処理が終わったらfetchTodos()を呼び出し、最新のリストで画面を更新します。

変更点: ToDoの編集モードを開始する処理の実装

/src/app/page.tsx
1+  const handleEdit = (index: number) => {
2+    setEditIndex(index)
3+    setEditText(todos[index])
4+  }

このhandleEdit関数は、「編集」ボタンがクリックされたときに、該当するToDoを編集モードに切り替えるための処理です。 setEditIndex(index)で、editIndexの状態を引数で受け取ったindexに更新します。これにより、どのToDoが編集中かをアプリケーションが記憶します。 setEditText(todos[index])で、編集用の入力欄の初期値として、現在編集しようとしているToDoのテキスト(todos[index])をeditText状態にセットします。

変更点: ToDoを更新する処理の実装

/src/app/page.tsx
1+  const handleUpdate = async () => {
2+    if (editIndex === null || !editText.trim()) return
3+    await fetch('/api/todos', {
4+      method: 'PATCH',
5+      headers: { 'Content-Type': 'application/json' },
6+      body: JSON.stringify({ index: editIndex, newTodo: editText }),
7+    })
8+    setEditIndex(null)
9+    setEditText('')
10+    fetchTodos()
11+  }

このhandleUpdate関数は、編集モード中に「更新」ボタンがクリックされたときの処理です。 if (editIndex === null || !editText.trim()) returnは、編集中でない場合や、編集後のテキストが空の場合は処理を中断します。 await fetch('/api/todos', { ... })で、/api/todosにPATCHリクエストを送信します。

  • method: 'PATCH': 通信方法がPATCHであることを指定します。
  • body: JSON.stringify({ index: editIndex, newTodo: editText }): 更新対象のインデックス番号(editIndex)と、新しいテキスト(editText)をJSON形式でサーバーに送信します。 setEditIndex(null)setEditText('')で、更新後に編集モードを解除し、編集用テキストを空に戻します。 最後にfetchTodos()を呼び出し、最新のリストで画面を更新します。

変更点: 初期表示時にToDoリストを読み込む処理

/src/app/page.tsx
1+  useEffect(() => {
2+    fetchTodos()
3+  }, [])

ここでは、ReactのuseEffectフックを使っています。 useEffectは、コンポーネントのライフサイクルにおける特定のタイミングで副作用(ここではAPI通信)を実行するためのフックです。 第二引数に空の配列[]を渡すと、useEffectの中の処理(ここではfetchTodos())は、コンポーネントが最初に画面に表示された時に一度だけ実行されます。 これにより、ユーザーがページを開いた瞬間に、自動的にサーバーからToDoリストを読み込んで表示することができます。

変更点: UI(画面の見た目)の全体構造の変更

/src/app/page.tsx
1-    <main className="flex min-h-screen items-center justify-center bg-gray-100">
2-      <h1 className="text-4xl font-bold text-blue-600">
3-        Welcome to My Todo App!
4-      </h1>
5+    <main className="min-h-screen bg-gray-100 p-6">
6+      <div className="max-w-md mx-auto bg-white rounded-xl shadow-md p-6">
7+        <h1 className="text-2xl font-bold mb-4 text-center text-blue-600">My Todo App</h1>

ここでは、アプリケーションの基本的なレイアウトを定義しています。classNameに指定されているのはTailwind CSSというCSSフレームワークのクラスで、見た目を整えるために使われています。 以前のシンプルなウェルカムメッセージから、ToDoアプリとしての体裁を整えるための構造に変更されています。

  • main: ページ全体のコンテナです。背景色などを設定しています。
  • div: アプリのコンテンツを中央に配置し、白い背景と影を付けてカードのように見せています。
  • h1: アプリのタイトルを表示しています。

変更点: 新規ToDo追加フォームのUI実装

/src/app/page.tsx
1+        {/* 新規追加フォーム */}
2+        <div className="flex gap-2 mb-4">
3+          <input
4+            type="text"
5+            value={input}
6+            onChange={(e) => setInput(e.target.value)}
7+            className="flex-1 border border-gray-300 rounded px-3 py-2"
8+            placeholder="Add new todo"
9+          />
10+          <button
11+            onClick={handleAdd}
12+            className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded"
13+          >
14+            Add
15+          </button>
16+        </div>

このコードは、新しいToDoを入力して追加するためのフォーム部分のUIです。

  • input: テキスト入力欄です。
    • value={input}: 入力欄に表示されるテキストを、Reactの状態inputと連携させています。
    • onChange={(e) => setInput(e.target.value)}: ユーザーが何か入力するたびにonChangeイベントが発生し、setInput関数を使ってinputの状態を更新します。これにより、入力内容がリアルタイムにReactの状態に反映されます。
  • button: 「Add」ボタンです。
    • onClick={handleAdd}: このボタンがクリックされると、先ほど定義したhandleAdd関数が実行され、ToDoの追加処理が走ります。

変更点: ToDoリストの表示と繰り返し処理

/src/app/page.tsx
1+        {/* Todoリスト */}
2+        <ul className="space-y-2">
3+          {todos.map((todo, index) => (
4+            <li
5+              key={index}
6+              className="flex items-center justify-between bg-gray-50 p-2 rounded"
7+            >
8+              {/* ... 中身は後述 ... */}
9+            </li>
10+          ))}
11+        </ul>

ここでは、todos状態に保存されているToDoリストを画面に表示しています。

  • ul: HTMLの順序なしリスト要素で、ToDoリスト全体を囲みます。
  • {todos.map((todo, index) => ( ... ))}: JavaScriptのmapメソッドを使って、todos配列の各要素を順番に取り出し、それぞれに対応するHTML(li要素)を生成しています。
    • todo: 配列内の個々のToDoの文字列です。
    • index: そのToDoが配列の何番目にあるかを示すインデックス番号です。
    • key={index}: Reactがリストの各項目を効率的に識別・管理するために必要な、一意の目印です。

変更点: 編集状態に応じた表示の切り替え

/src/app/page.tsx
1+              <div className="flex-1">
2+                {editIndex === index ? (
3+                  <input
4+                    type="text"
5+                    value={editText}
6+                    onChange={(e) => setEditText(e.target.value)}
7+                    className="w-full border-b border-blue-400 px-1 focus:outline-none"
8+                  />
9+                ) : (
10+                  <span>{todo}</span>
11+                )}
12+              </div>

この部分は、各ToDo項目が通常表示か編集モードかを判断し、表示を切り替えています。 {editIndex === index ? ( ... ) : ( ... )}は、三項演算子と呼ばれる条件分岐です。

  • もしeditIndex(現在編集中のToDoの番号)と、このli要素のindexが同じなら(つまり、この項目が編集対象なら)、テキスト入力欄(input)を表示します。入力欄の値はeditText状態と連携しています。
  • そうでなければ(通常表示の場合)、<span>{todo}</span>でToDoのテキストをそのまま表示します。

変更点: 編集・更新・削除ボタンの実装

/src/app/page.tsx
1+              <div className="flex gap-2 ml-2">
2+                {editIndex === index ? (
3+                  <button
4+                    onClick={handleUpdate}
5+                    className="text-green-600 hover:underline text-sm"
6+                  >
7+                    更新
8+                  </button>
9+                ) : (
10+                  <button
11+                    onClick={() => handleEdit(index)}
12+                    className="text-blue-600 hover:underline text-sm"
13+                  >
14+                    編集
15+                  </button>
16+                )}
17+
18+                <button
19+                  onClick={() => handleDelete(index)}
20+                  className="text-red-500 hover:underline text-sm"
21+                >
22+                  削除
23+                </button>
24+              </div>

各ToDo項目の右側に表示される操作ボタンを実装しています。 ここでも三項演算子を使い、編集モードかどうかで表示するボタンを切り替えています。

  • 編集中 (editIndex === indexがtrue) の場合: 「更新」ボタンを表示します。クリックするとhandleUpdate関数が実行されます。
  • 通常表示の場合: 「編集」ボタンを表示します。クリックするとhandleEdit(index)が実行され、その項目が編集モードに切り替わります。
  • 削除ボタン: 編集モードに関わらず常に表示されます。クリックするとhandleDelete(index)が実行され、その項目が削除されます。onClickに関数を渡す際に() => ...と書くことで、クリックされた瞬間に引数indexを渡して関数を実行できます。

おわりに

今回はNext.jsを使い、簡単なToDoアプリの作成を通じてWeb開発の基本となるCRUD機能を一通り実装しました。バックエンドではAPI Routesを利用して、データの追加(POST)や削除(DELETE)といったリクエストに応じた処理を作成する方法を学びました。フロントエンドではuseStateで入力値やToDoリストの状態を管理し、fetch関数を使ってAPIと通信することで、画面を動的に更新する流れを体験しました。この記事で学んだフロントエンドとバックエンドが連携する仕組みは、あらゆるWebアプリケーション開発の基礎となる重要な知識です。

関連コンテンツ