【Next.js】Zodでバリデーション機能を実装する|簡単なToDoアプリの作成

Next.jsで人気のライブラリ「Zod」を使い、フォームの入力値チェック(バリデーション)機能を実装する方法を解説します。簡単なToDoアプリのユーザー登録やタスク追加を例に、文字数制限やメールアドレス形式といったルールを定義し、エラーメッセージを表示する具体的な手順を学べます。

作成日: 更新日:

開発環境

  • OS: Windows10
  • Visual Studio Code: 1.73.0
  • node: 22.14.0
  • react: 19.0.0
  • next: 15.3.2
  • tailwindcss: 4
  • Prisma: 6.8.2
  • NextAuth: 5.8.0
  • @auth/prisma-adapter: 2.9.1
  • @prisma/client: 6.8.2
  • bcrypt: 6.0.0
  • @types/bcrypt: 5.0.2
  • zod: 3.25.56

サンプルコード

/package.json

/package.json
1     "next-auth": "^4.24.11",
2     "prisma": "^6.8.2",
3     "react": "^19.0.0",
4-    "react-dom": "^19.0.0"
5+    "react-dom": "^19.0.0",
6+    "zod": "^3.25.56"
7   },
8   "devDependencies": {
9     "@eslint/eslintrc": "^3",
10

/src/app/auth/register/page.tsx

/src/app/auth/register/page.tsx
1 
2 import { useState } from "react"
3 import { useRouter } from "next/navigation"
4+import { z } from "zod"
5+
6+const schema = z.object({
7+  name: z.string().min(3, { message: "名前は3文字以上で入力してください" }),
8+  email: z.string().email({ message: "正しいメールアドレスを入力してください" }),
9+  password: z.string().min(6, { message: "パスワードは6文字以上で入力してください" }),
10+})
11 
12 export default function RegisterPage() {
13   const [email, setEmail] = useState("")
14   const [password, setPassword] = useState("")
15   const [name, setName] = useState("")
16+  const [errors, setErrors] = useState<{ name?: string; email?: string; password?: string }>({})
17   const router = useRouter()
18 
19   const handleRegister = async (e: React.FormEvent) => {
20     e.preventDefault()
21+    setErrors({})
22+
23+    const result = schema.safeParse({ name, email, password })
24+    if (!result.success) {
25+      const fieldErrors: { name?: string; email?: string; password?: string } = {}
26+      result.error.errors.forEach(({ path, message }) => {
27+        if (path[0] === "name") fieldErrors.name = message
28+        if (path[0] === "email") fieldErrors.email = message
29+        if (path[0] === "password") fieldErrors.password = message
30+      })
31+      setErrors(fieldErrors)
32+      return
33+    }
34+
35     const res = await fetch("/api/auth/register", {
36       method: "POST",
37       headers: { "Content-Type": "application/json" },
38       <form
39         onSubmit={handleRegister}
40         className="bg-white p-8 rounded shadow-md w-full max-w-md"
41+        noValidate
42       >
43         <h2 className="text-2xl font-bold mb-6 text-center">ユーザー登録</h2>
44 
45           <label className="block text-gray-700 mb-2">名前</label>
46           <input
47             type="text"
48-            className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-400"
49+            className={`w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 ${errors.name ? "border-red-500 focus:ring-red-400" : "focus:ring-blue-400"}`}
50             value={name}
51             onChange={(e) => setName(e.target.value)}
52             placeholder="山田 太郎"
53           />
54+          {errors.name && (
55+            <p className="text-red-500 text-sm mt-1">{errors.name}</p>
56+          )}
57         </div>
58 
59         <div className="mb-4">
60           <label className="block text-gray-700 mb-2">メールアドレス</label>
61           <input
62             type="email"
63-            className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-400"
64+            className={`w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 ${errors.email ? "border-red-500 focus:ring-red-400" : "focus:ring-blue-400"}`}
65             value={email}
66             onChange={(e) => setEmail(e.target.value)}
67             placeholder="example@example.com"
68           />
69+          {errors.email && (
70+            <p className="text-red-500 text-sm mt-1">{errors.email}</p>
71+          )}
72         </div>
73 
74         <div className="mb-6">
75           <label className="block text-gray-700 mb-2">パスワード</label>
76           <input
77             type="password"
78-            className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-400"
79+            className={`w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 ${errors.password ? "border-red-500 focus:ring-red-400" : "focus:ring-blue-400"}`}
80             value={password}
81             onChange={(e) => setPassword(e.target.value)}
82             placeholder="パスワード"
83           />
84+          {errors.password && (
85+            <p className="text-red-500 text-sm mt-1">{errors.password}</p>
86+          )}
87         </div>
88 
89         <button
90

/src/app/auth/signin/page.tsx

/src/app/auth/signin/page.tsx
1 import { signIn } from "next-auth/react"
2 import { useState } from "react"
3 import { useRouter } from "next/navigation"
4+import { z } from "zod"
5+
6+const schema = z.object({
7+  email: z.string().email({ message: "正しいメールアドレスを入力してください" }),
8+  password: z.string().min(6, { message: "パスワードは6文字以上で入力してください" }),
9+})
10 
11 export default function SignInPage() {
12   const [email, setEmail] = useState("")
13   const [password, setPassword] = useState("")
14+  const [errors, setErrors] = useState<{ email?: string; password?: string }>({})
15   const router = useRouter()
16 
17   const handleSubmit = async (e: React.FormEvent) => {
18     e.preventDefault()
19+    setErrors({})
20+
21+    const result = schema.safeParse({ email, password })
22+    if (!result.success) {
23+      const fieldErrors: { email?: string; password?: string } = {}
24+      result.error.errors.forEach(({ path, message }) => {
25+        if (path[0] === "email") fieldErrors.email = message
26+        if (path[0] === "password") fieldErrors.password = message
27+      })
28+      setErrors(fieldErrors)
29+      return
30+    }
31+
32     const res = await signIn("credentials", {
33       email,
34       password,
35       <form
36         onSubmit={handleSubmit}
37         className="bg-white p-8 rounded shadow-md w-full max-w-md"
38+        noValidate
39       >
40         <h2 className="text-2xl font-bold mb-6 text-center">サインイン</h2>
41 
42             placeholder="example@example.com"
43             value={email}
44             onChange={(e) => setEmail(e.target.value)}
45-            className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-400"
46+            className={`w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 ${errors.email ? "border-red-500 focus:ring-red-400" : "focus:ring-blue-400"}`}
47           />
48+          {errors.email && (
49+            <p className="text-red-500 text-sm mt-1">{errors.email}</p>
50+          )}
51         </div>
52 
53         <div className="mb-6">
54             placeholder="パスワード"
55             value={password}
56             onChange={(e) => setPassword(e.target.value)}
57-            className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-400"
58+            className={`w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 ${errors.password ? "border-red-500 focus:ring-red-400" : "focus:ring-blue-400"}`}
59           />
60+          {errors.password && (
61+            <p className="text-red-500 text-sm mt-1">{errors.password}</p>
62+          )}
63         </div>
64 
65         <button
66

/src/app/page.tsx

/src/app/page.tsx
1 
2 import { useEffect, useState } from 'react';
3 import { useFlashMessage } from '@/app/context/FlashMessageContext';
4+import { z } from 'zod';
5 
6 type Todo = {
7   id: number;
8   text: string;
9 };
10 
11+const todoSchema = z.object({
12+  text: z.string().min(1, '入力は必須です').max(100, '100文字以内で入力してください'),
13+});
14+
15 export default function Home() {
16   const [todos, setTodos] = useState<Todo[]>([]);
17   const [input, setInput] = useState('');
18   const [editId, setEditId] = useState<number | null>(null);
19   const [editText, setEditText] = useState('');
20   const { showMessage } = useFlashMessage();
21+  const [inputError, setInputError] = useState('');
22+  const [editError, setEditError] = useState('');
23 
24   const fetchTodos = async () => {
25     const res = await fetch('/api/todos');
26   };
27 
28   const handleAdd = async () => {
29-    if (!input.trim()) return;
30+    const result = todoSchema.safeParse({ text: input });
31+
32+    if (!result.success) {
33+      setInputError(result.error.errors[0].message);
34+      return;
35+    }
36+
37+    setInputError('');
38 
39     const res = await fetch('/api/todos', {
40       method: 'POST',
41   };
42 
43   const handleUpdate = async () => {
44-    if (editId === null || !editText.trim()) return;
45+    if (editId === null) return;
46+
47+    const result = todoSchema.safeParse({ text: editText });
48+
49+    if (!result.success) {
50+      setEditError(result.error.errors[0].message);
51+      return;
52+    }
53+
54+    setEditError('');
55 
56     const res = await fetch('/api/todos', {
57       method: 'PATCH',
58           </button>
59         </div>
60 
61+        {/* 新規追加フォームの下に表示 */}
62+        {inputError && <p className="text-red-500 text-sm mt-1">{inputError}</p>}
63+
64         {/* Todoリスト */}
65         <ul className="space-y-2">
66           {todos.map((todo) => (
67                 ) : (
68                   <span>{todo.text}</span>
69                 )}
70+                {/* 編集フォームの下に表示(該当todoだけ) */}
71+                {editId === todo.id && editError && (
72+                  <p className="text-red-500 text-sm mt-1">{editError}</p>
73+                )}
74               </div>
75 
76+
77               <div className="flex gap-2 ml-2">
78                 {editId === todo.id ? (
79                   <button
80

コード解説

変更点: Zodライブラリのインストール

/package.json
1-    "react-dom": "^19.0.0"
2+    "react-dom": "^19.0.0",
3+    "zod": "^3.25.56"

まず、プロジェクトでZodライブラリを使えるようにインストールします。package.jsonファイルに"zod": "^3.25.56"が追加されているのは、npm install zod または yarn add zod コマンドを実行して、Zodがプロジェクトの依存関係に追加されたことを示しています。これにより、コード内でZodの機能をインポートして利用できるようになります。

変更点: ユーザー登録フォームのバリデーションルールを定義

/src/app/auth/register/page.tsx
1 import { useState } from "react"
2 import { useRouter } from "next/navigation"
3+import { z } from "zod"
4+
5+const schema = z.object({
6+  name: z.string().min(3, { message: "名前は3文字以上で入力してください" }),
7+  email: z.string().email({ message: "正しいメールアドレスを入力してください" }),
8+  password: z.string().min(6, { message: "パスワードは6文字以上で入力してください" }),
9+})

ここでは、Zodを使ってユーザー登録フォームの入力値に対するバリデーションルール(スキーマ)を定義しています。 まずimport { z } from "zod"でZodをインポートします。 z.object({...})は、フォーム全体の入力値がオブジェクト形式であることを定義しています。 その中に、各入力フィールド(name, email, password)のルールを記述します。

  • name: z.string().min(3, ...): nameは文字列型(string)で、最小3文字(min(3))以上である必要があります。ルールに違反した場合のエラーメッセージも指定できます。
  • email: z.string().email(...): emailは文字列型で、メールアドレスの形式(email())に沿っている必要があります。
  • password: z.string().min(6, ...): passwordは文字列型で、最小6文字(min(6))以上である必要があります。

変更点: ユーザー登録フォームのエラーメッセージを管理するStateを追加

/src/app/auth/register/page.tsx
1   const [password, setPassword] = useState("")
2   const [name, setName] = useState("")
3+  const [errors, setErrors] = useState<{ name?: string; email?: string; password?: string }>({})
4   const router = useRouter()

バリデーションによって発生したエラーメッセージを画面に表示するために、ReactのuseStateを使ってエラー情報を管理する状態(state)errorsを新しく定義しています。初期値は空のオブジェクト{}です。 このerrorsオブジェクトは、name, email, passwordの各キーに対して、文字列型のエラーメッセージを格納できるようになっています。?が付いているのは、エラーがない場合はそのキーが存在しなくてもよい、ということを示しています。

変更点: ユーザー登録フォーム送信時にバリデーションを実行

/src/app/auth/register/page.tsx
1   const handleRegister = async (e: React.FormEvent) => {
2     e.preventDefault()
3+    setErrors({})
4+
5+    const result = schema.safeParse({ name, email, password })
6+    if (!result.success) {
7+      const fieldErrors: { name?: string; email?: string; password?: string } = {}
8+      result.error.errors.forEach(({ path, message }) => {
9+        if (path[0] === "name") fieldErrors.name = message
10+        if (path[0] === "email") fieldErrors.email = message
11+        if (path[0] === "password") fieldErrors.password = message
12+      })
13+      setErrors(fieldErrors)
14+      return
15+    }
16+
17     const res = await fetch("/api/auth/register", {
18       method: "POST",
19       headers: { "Content-Type": "application/json" },

フォームが送信されたときに実行されるhandleRegister関数内に、Zodによるバリデーション処理を追加しています。

  1. setErrors({}): まず、以前のエラー表示を消すためにerrors stateを空のオブジェクトにリセットします。
  2. schema.safeParse(...): 先ほど定義したschemaを使い、現在の入力値(name, email, password)を検証します。safeParseは検証に失敗してもエラーでプログラムを止めないため、安全にバリデーション結果を扱えます。
  3. if (!result.success): 検証に失敗した場合(result.successfalseの場合)の処理です。
  4. result.error.errors.forEach(...): どのフィールドでどのようなエラーが発生したかの情報を取り出し、fieldErrorsという新しいオブジェクトに格納します。
  5. setErrors(fieldErrors): 作成したfieldErrorsオブジェクトをerrors stateにセットします。これにより、画面にエラーメッセージが表示されるようになります。
  6. return: バリデーションに失敗したため、APIへのリクエスト処理に進まず、ここに関数の実行を終了します。

変更点: ブラウザ標準のバリデーションを無効化

/src/app/auth/register/page.tsx
1       <form
2         onSubmit={handleRegister}
3         className="bg-white p-8 rounded shadow-md w-full max-w-md"
4+        noValidate
5       >

<form>タグにnoValidateという属性を追加しています。これを追加することで、HTMLが標準で持っているフォームのバリデーション機能(例えば、type="email"の入力欄に@がないと送信できないようにするなど)が無効になります。今回はZodで独自の、より詳細なバリデーションルールを実装しているため、ブラウザの標準機能と競合しないように無効化しています。

変更点: ユーザー登録フォームにエラーメッセージとスタイルを適用

/src/app/auth/register/page.tsx
1-            className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-400"
2+            className={`w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 ${errors.name ? "border-red-500 focus:ring-red-400" : "focus:ring-blue-400"}`}
3...
4+          {errors.name && (
5+            <p className="text-red-500 text-sm mt-1">{errors.name}</p>
6+          )}

バリデーションの結果をユーザーに視覚的に伝えるための変更です。 まず、classNameの指定が変わっています。errors.nameにエラーメッセージが存在する場合、三項演算子 (? :) を使ってborder-red-500のようなCSSクラスを適用し、入力欄の枠線を赤くしています。エラーがなければ、通常通りのスタイルが適用されます。 次に、inputタグの下に新しいコードブロックが追加されています。{errors.name && ...}という記述は、「errors.nameが存在する場合にのみ、後ろの要素を表示する」という意味です。これにより、バリデーションエラーが発生したフィールドの下に、<p>タグで赤文字のエラーメッセージが表示されるようになります。emailpasswordの入力欄にも同様の変更が加えられています。

変更点: サインインフォームのバリデーションルールを定義

/src/app/auth/signin/page.tsx
1 import { signIn } from "next-auth/react"
2 import { useState } from "react"
3 import { useRouter } from "next/navigation"
4+import { z } from "zod"
5+
6+const schema = z.object({
7+  email: z.string().email({ message: "正しいメールアドレスを入力してください" }),
8+  password: z.string().min(6, { message: "パスワードは6文字以上で入力してください" }),
9+})

こちらはサインインページです。ユーザー登録ページと同様に、Zodを使ってサインインフォーム用のバリデーションルール(スキーマ)を定義しています。サインインに必要なemailpasswordのフィールドに対して、それぞれメールアドレス形式であること、6文字以上であること、というルールと、違反した場合のエラーメッセージを設定しています。

変更点: サインインフォームのエラーメッセージを管理するStateを追加

/src/app/auth/signin/page.tsx
1   const [email, setEmail] = useState("")
2   const [password, setPassword] = useState("")
3+  const [errors, setErrors] = useState<{ email?: string; password?: string }>({})
4   const router = useRouter()

サインインフォームでも、バリデーションエラーのメッセージを保持するためのerrors stateをuseStateで定義しています。ユーザー登録ページと同様に、emailpasswordのキーに対応するエラーメッセージを格納するためのオブジェクトとして初期化されています。

変更点: サインインフォーム送信時にバリデーションを実行

/src/app/auth/signin/page.tsx
1   const handleSubmit = async (e: React.FormEvent) => {
2     e.preventDefault()
3+    setErrors({})
4+
5+    const result = schema.safeParse({ email, password })
6+    if (!result.success) {
7+      const fieldErrors: { email?: string; password?: string } = {}
8+      result.error.errors.forEach(({ path, message }) => {
9+        if (path[0] === "email") fieldErrors.email = message
10+        if (path[0] === "password") fieldErrors.password = message
11+      })
12+      setErrors(fieldErrors)
13+      return
14+    }
15+
16     const res = await signIn("credentials", {
17       email,
18       password,

サインインボタンがクリックされたときに実行されるhandleSubmit関数に、Zodによるバリデーション処理を追加しています。 処理の流れはユーザー登録ページと全く同じです。

  1. エラーメッセージをリセットする。
  2. schema.safeParseで入力値を検証する。
  3. 検証に失敗した場合、エラー内容をerrors stateにセットし、サインイン処理(signIn)に進まずに関数を終了する。 これにより、不正な形式のデータでサインイン処理が実行されるのを防ぎます。

変更点: サインインフォームにエラーメッセージとスタイルを適用

/src/app/auth/signin/page.tsx
1-            className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-400"
2+            className={`w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 ${errors.email ? "border-red-500 focus:ring-red-400" : "focus:ring-blue-400"}`}
3           />
4+          {errors.email && (
5+            <p className="text-red-500 text-sm mt-1">{errors.email}</p>
6+          )}

サインインフォームの見た目に関する変更です。これもユーザー登録ページと同様で、errors stateにエラー情報があるかどうかをチェックしています。 エラーがある場合は、入力欄の枠線を赤くし、入力欄の下に赤文字でエラーメッセージを表示します。これにより、ユーザーはどこを修正すればよいかが一目でわかります。passwordの入力欄にも同様の変更が加えられています。

変更点: ToDo入力フォームのバリデーションルールを定義

/src/app/page.tsx
1 import { useEffect, useState } from 'react';
2 import { useFlashMessage } from '@/app/context/FlashMessageContext';
3+import { z } from 'zod';
4 
5 type Todo = {
6   id: number;
7   text: string;
8 };
9 
10+const todoSchema = z.object({
11+  text: z.string().min(1, '入力は必須です').max(100, '100文字以内で入力してください'),
12+});
13+

ToDoアプリのメインページにもZodを導入しています。ここでは、新しいToDoを追加したり、既存のToDoを編集したりする際のテキスト入力に対するバリデーションルールtodoSchemaを定義しています。 textフィールドに対して、z.string()で文字列型であることを指定し、.min(1, ...)で空の入力を禁止(1文字以上を必須に)、.max(100, ...)で100文字以内という文字数制限を設けています。

変更点: ToDoフォームのエラーメッセージを管理するStateを追加

/src/app/page.tsx
1   const [editText, setEditText] = useState('');
2   const { showMessage } = useFlashMessage();
3+  const [inputError, setInputError] = useState('');
4+  const [editError, setEditError] = useState('');

ToDoアプリでは、タスクを「新規追加」するフォームと、既存のタスクを「編集」するフォームの2種類があるため、それぞれのエラーメッセージを管理するStateをuseStateで定義しています。

  • inputError: 新規追加フォーム用のエラーステートです。
  • editError: 編集フォーム用のエラーステートです。 どちらも初期値は空文字列''です。

変更点: ToDo新規追加時にバリデーションを実行

/src/app/page.tsx
1   const handleAdd = async () => {
2-    if (!input.trim()) return;
3+    const result = todoSchema.safeParse({ text: input });
4+
5+    if (!result.success) {
6+      setInputError(result.error.errors[0].message);
7+      return;
8+    }
9+
10+    setInputError('');
11 
12     const res = await fetch('/api/todos', {
13       method: 'POST',

新しいToDoを追加するhandleAdd関数内の処理が変更されています。以前はif (!input.trim())という単純な空文字チェックだけでしたが、これをZodを使ったバリデーションに置き換えています。 todoSchema.safeParseで入力値inputを検証し、失敗した場合(!result.success)は、setInputErrorを使ってエラーメッセージをinputError stateにセットし、処理を中断します。 検証に成功した場合は、setInputError('')でエラーメッセージをクリアしてから、APIリクエストの処理に進みます。

変更点: ToDo更新時にバリデーションを実行

/src/app/page.tsx
1   const handleUpdate = async () => {
2-    if (editId === null || !editText.trim()) return;
3+    if (editId === null) return;
4+
5+    const result = todoSchema.safeParse({ text: editText });
6+
7+    if (!result.success) {
8+      setEditError(result.error.errors[0].message);
9+      return;
10+    }
11+
12+    setEditError('');
13 
14     const res = await fetch('/api/todos', {
15       method: 'PATCH',

ToDoを更新するhandleUpdate関数にも、同様にZodによるバリデーション処理が追加されました。 こちらは編集中のテキストeditTexttodoSchemaで検証します。検証に失敗した場合は、setEditErrorを使ってeditError stateにエラーメッセージをセットし、処理を中断します。成功した場合は、setEditError('')でエラーメッセージをクリアしてから更新処理に進みます。

変更点: ToDoフォームにエラーメッセージを表示

/src/app/page.tsx
1         </div>
2 
3+        {/* 新規追加フォームの下に表示 */}
4+        {inputError && <p className="text-red-500 text-sm mt-1">{inputError}</p>}
5+
6         {/* Todoリスト */}
7         <ul className="space-y-2">
8           {todos.map((todo) => (
9                 ) : (
10                   <span>{todo.text}</span>
11                 )}
12+                {/* 編集フォームの下に表示(該当todoだけ) */}
13+                {editId === todo.id && editError && (
14+                  <p className="text-red-500 text-sm mt-1">{editError}</p>
15+                )}
16               </div>

最後に、バリデーションエラーのメッセージを画面に表示するためのJSXコードを追加しています。

  • {inputError && ...}: 新規追加フォームの下に、inputErrorにメッセージがある場合のみ、赤文字でエラー内容を表示します。
  • {editId === todo.id && editError && ...}: ToDoリストの中で、現在編集中のToDo(editId === todo.id)に限り、editErrorにメッセージがある場合にエラー内容を表示します。これにより、他のToDoには影響を与えず、編集中の項目にだけエラーメッセージを表示できます。

おわりに

今回は、Zodライブラリを使ってNext.jsアプリケーションにフォームのバリデーション機能を実装する方法を学びました。z.string().min(3)のように直感的なコードで文字数制限やメールアドレス形式といったルールを定義し、safeParseメソッドで簡単に入力値をチェックできることを確認しました。バリデーション結果をuseStateで管理することで、入力欄のスタイル変更やエラーメッセージの表示といったUIへの反映もスムーズに行えました。この方法はユーザー登録からToDoの追加まで幅広く応用でき、アプリケーションの品質とユーザー体験を向上させるための強力な手段となります。

関連コンテンツ