【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.json1 "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.tsx1 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.tsx1 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.tsx1 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.json1- "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.tsx1 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.tsx1 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.tsx1 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によるバリデーション処理を追加しています。
setErrors({}): まず、以前のエラー表示を消すためにerrorsstateを空のオブジェクトにリセットします。schema.safeParse(...): 先ほど定義したschemaを使い、現在の入力値(name,email,password)を検証します。safeParseは検証に失敗してもエラーでプログラムを止めないため、安全にバリデーション結果を扱えます。if (!result.success): 検証に失敗した場合(result.successがfalseの場合)の処理です。result.error.errors.forEach(...): どのフィールドでどのようなエラーが発生したかの情報を取り出し、fieldErrorsという新しいオブジェクトに格納します。setErrors(fieldErrors): 作成したfieldErrorsオブジェクトをerrorsstateにセットします。これにより、画面にエラーメッセージが表示されるようになります。return: バリデーションに失敗したため、APIへのリクエスト処理に進まず、ここに関数の実行を終了します。
変更点: ブラウザ標準のバリデーションを無効化
/src/app/auth/register/page.tsx1 <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.tsx1- 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>タグで赤文字のエラーメッセージが表示されるようになります。emailとpasswordの入力欄にも同様の変更が加えられています。
変更点: サインインフォームのバリデーションルールを定義
/src/app/auth/signin/page.tsx1 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を使ってサインインフォーム用のバリデーションルール(スキーマ)を定義しています。サインインに必要なemailとpasswordのフィールドに対して、それぞれメールアドレス形式であること、6文字以上であること、というルールと、違反した場合のエラーメッセージを設定しています。
変更点: サインインフォームのエラーメッセージを管理するStateを追加
/src/app/auth/signin/page.tsx1 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で定義しています。ユーザー登録ページと同様に、emailとpasswordのキーに対応するエラーメッセージを格納するためのオブジェクトとして初期化されています。
変更点: サインインフォーム送信時にバリデーションを実行
/src/app/auth/signin/page.tsx1 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によるバリデーション処理を追加しています。
処理の流れはユーザー登録ページと全く同じです。
- エラーメッセージをリセットする。
schema.safeParseで入力値を検証する。- 検証に失敗した場合、エラー内容を
errorsstateにセットし、サインイン処理(signIn)に進まずに関数を終了する。 これにより、不正な形式のデータでサインイン処理が実行されるのを防ぎます。
変更点: サインインフォームにエラーメッセージとスタイルを適用
/src/app/auth/signin/page.tsx1- 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.tsx1 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.tsx1 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.tsx1 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.tsx1 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によるバリデーション処理が追加されました。
こちらは編集中のテキストeditTextをtodoSchemaで検証します。検証に失敗した場合は、setEditErrorを使ってeditError stateにエラーメッセージをセットし、処理を中断します。成功した場合は、setEditError('')でエラーメッセージをクリアしてから更新処理に進みます。
変更点: ToDoフォームにエラーメッセージを表示
/src/app/page.tsx1 </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の追加まで幅広く応用でき、アプリケーションの品質とユーザー体験を向上させるための強力な手段となります。