1. HOME
  2. ブログ
  3. エンジニアリング
  4. Next.jsにZodを導入してバリデーションを実装しよう

BLOG

ブログ

エンジニアリング

Next.jsにZodを導入してバリデーションを実装しよう

本章では、Next.jsで開発されたアプリケーションにバリデーション機能を実装する方法を学びます。

チュートリアルの全体像

本チュートリアルを通じて学べる内容は以下のようになります。

  1. Next.jsの開発環境をセットアップ
  2. 画面デザインしながら基礎を学ぼう
  3. App Routerを理解して使ってみよう
  4. Next.js+Postgres+PrismaでDB設計
  5. サーバコンポーネントでデータ取得
  6. サーバアクションでデータ変更
  7. Vercel Blobで画像アップロード実装
  8. Suspenseでローディングをリッチに
  9. Zodを使ってバリデーション実装
  10. useActionStateでローディング実装
  11. Auth.js v5で認証機能導入
  12. 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実行中にローディングを表示する方法を学んでいきます。

  1. この記事へのコメントはありません。

  1. この記事へのトラックバックはありません。

関連記事