Next.jsにZodを導入してバリデーションを実装しよう
本章では、Next.jsで開発されたアプリケーションにバリデーション機能を実装する方法を学びます。
目次
チュートリアルの全体像
本チュートリアルを通じて学べる内容は以下のようになります。
- Next.jsの開発環境をセットアップ
- 画面デザインしながら基礎を学ぼう
- App Routerを理解して使ってみよう
- Next.js+Postgres+PrismaでDB設計
- サーバコンポーネントでデータ取得
- サーバアクションでデータ変更
- Vercel Blobで画像アップロード実装
- Suspenseでローディングをリッチに
- Zodを使ってバリデーション実装
- useActionStateでローディング実装
- Auth.js v5で認証機能導入
- Route HandlersでAPIを設計
動画で学びたい方はこちらから!
Zodとは
Zodとは、TypeScript向けに開発されたバリデーションライブラリです。
TypeScriptの型システムを使ってわかりやすくバリデーションロジックを記述できるので、Next.jsの開発において非常に人気のライブラリです。
Zodインストール
まずは、以下のコマンドでZodをインストールしましょう。
npm i zod
バリデーション実装
新規会員登録ページのバリデーションを実装していきましょう。
スキーマ定義
lib/actions.ts
import { z } from "zod";
const RegisterUserSchema = z.object({
name: z.string().min(1, "ユーザ名は必須です。"),
email: z.string().email("メールアドレスの形式が正しくありません。"),
password: z.string().min(8, "パスワードは8文字以上で設定してください。"),
passwordConfirmation: z
.string()
.min(8, "確認用パスワードは8文字以上で設定してください。"),
});
export async function registerUser(
formData: FormData,
) {
// 省略
}
状態定義
import { z } from "zod";
const RegisterUserSchema = z.object({
name: z.string().min(1, "ユーザ名は必須です。"),
email: z.string().email("メールアドレスの形式が正しくありません。"),
password: z.string().min(8, "パスワードは8文字以上で設定してください。"),
passwordConfirmation: z
.string()
.min(8, "確認用パスワードは8文字以上で設定してください。"),
});
type RegisterUserState = {
errors?: {
name?: string[];
email?: string[];
password?: string[];
passwordConfirmation?: string[];
};
message?: string | null;
};
export async function registerUser(
formData: FormData,
) {
// 省略
}
パース
lib/actions.ts
export async function registerUser(
_state: RegisterUserState,
formData: FormData,
): Promise<RegisterUserState> {
const validatedFields = RegisterUserSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
passwordConfirmation: formData.get("passwordConfirmation"),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: "ユーザー登録に失敗しました。",
};
}
const { name, email, password } = validatedFields.data;
const bcryptedPassword = await bcrypt.hash(password, 10);
try {
await prisma.user.create({
data: {
name,
email,
password: bcryptedPassword,
},
});
return {
errors: {},
message: "ユーザー登録に成功しました。",
};
} catch (error) {
console.error(error);
throw new Error("ユーザー登録に失敗しました。");
}
}
ユーザー登録ページにエラー表示
"use client";
import { registerUser } from "@/lib/actions";
import Link from "next/link";
import { useActionState } from "react";
export default function RegisterForm() {
const initialState = {
errors: {},
message: null,
};
const [state, action] = useActionState(registerUser, initialState);
return (
<form action={action}>
<div>
<label className="block text-sm font-medium text-gray-700">
ユーザ名
</label>
<input
name="name"
required
className="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
<div>
{state.errors?.name &&
state.errors.name.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700">
メールアドレス
</label>
<input
name="email"
type="email"
required
aria-describedby="email-error"
className="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
<div>
{state.errors?.email &&
state.errors.email.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700">
パスワード
</label>
<input
type="password"
name="password"
required
minLength={8}
className="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
<div>
{state.errors?.password &&
state.errors.password.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700">
パスワード(確認用)
</label>
<input
type="password"
name="passwordConfirmation"
required
minLength={8}
className="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
<div>
{state.errors?.passwordConfirmation &&
state.errors.passwordConfirmation.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
<div className="mt-8 flex items-center justify-end">
<Link
className="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
href="/login"
>
すでにアカウントをお持ちの方はこちら
</Link>
<button
type="submit"
className="ml-4 inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"
>
登録
</button>
</div>
</form>
);
}
※動作確認をするときは、requiredなどのフロントエンド側のバリデーションを外してください。
独自バリデーションロジック定義
パスワードと確認用パスワードが一致しない
lib/actions.ts
const RegisterUserSchema = z
.object({
name: z.string().min(1, "ユーザ名は必須です。"),
email: z.string().email("メールアドレスの形式が正しくありません。"),
password: z.string().min(8, "パスワードは8文字以上で設定してください。"),
passwordConfirmation: z
.string()
.min(8, "確認用パスワードは8文字以上で設定してください。"),
})
.refine(
(args) => {
const { password, passwordConfirmation } = args;
return password === passwordConfirmation;
},
{
message: "パスワードと確認用パスワードが一致しません。",
path: ["passwordConfirmation"],
},
);
すでにメールアドレスが使われている
const RegisterUserSchema = z
.object({
name: z.string().min(1, "ユーザ名は必須です。"),
email: z.string().email("メールアドレスの形式が正しくありません。"),
password: z.string().min(8, "パスワードは8文字以上で設定してください。"),
passwordConfirmation: z
.string()
.min(8, "確認用パスワードは8文字以上で設定してください。"),
})
.refine(
(args) => {
const { password, passwordConfirmation } = args;
return password === passwordConfirmation;
},
{
message: "パスワードと確認用パスワードが一致しません。",
path: ["passwordConfirmation"],
},
)
.refine(
async (args) => {
const { email } = args;
const user = await prisma.user.findFirst({ where: { email } });
return !user;
},
{
message: "このメールアドレスはすでに使われています。",
path: ["email"],
},
);
lib/actions.ts
export async function registerUser(
_state: RegisterUserState,
formData: FormData,
): Promise<RegisterUserState> {
const validatedFields = await RegisterUserSchema.safeParseAsync({
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
passwordConfirmation: formData.get("passwordConfirmation"),
});
// 省略
}
まとめ
以上、Next.jsにZodを導入してバリデーションする方法についてでした!
次回は、ServerActions実行中にローディングを表示する方法を学んでいきます。
この記事へのコメントはありません。