🍊

Next.js + shadcn/ui + emailjs で作るモダンお問い合わせフォーム"

に公開

はじめに

こんにちは! 👋

この記事では、Next.js (App Router), Tailwind CSS, shadcn/ui, React Hook Form, Zod, そして emailjs を使って、実用的なお問い合わせフォームを作成する手順を、コードを中心に詳しく解説していきます。

特に、以下の点にフォーカスして説明します。

  • shadcn/ui コンポーネントを使ったフォームの構造化
  • React Hook Form によるフォームの状態管理
  • Zod を使った入力値のバリデーション(入力チェック)
  • emailjs を使ったフロントエンドからのメール送信処理

完成するフォームコンポーネントのコードはこちらです。これから、このコードの各部分がどのように機能しているのかを詳しく見ていきましょう!

src/components/ContactSection.tsx (完成形)
"use client";

import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { init, send } from "@emailjs/browser";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useState } from "react";


// Zod でバリデーションスキーマを定義
const formSchema = z.object({
  username: z
    .string()
    .min(2, { message: "お名前は2文字以上で入力してください。" })
    .max(50, { message: "お名前は50文字以下で入力してください。" }),
  email: z
    .string()
    .email({ message: "正しいメールアドレスを入力してください。" }),
  content: z
    .string()
    .min(10, { message: "メッセージは10文字以上で入力してください。" })
    .max(1000, { message: "メッセージは1000文字以下で入力してください。"}),
});

// ZodスキーマからTypeScriptの型を生成
type FormType = z.infer<typeof formSchema>;


export default function ContactSection() {
  const [isSending, setIsSending] = useState(false);

  const form = useForm<FormType>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: "",
      email: "",
      content: "",
    },
  });

  const onSubmit: SubmitHandler<FormType> = async (data: FormType) => {
    setIsSending(true);

    const userId = process.env.NEXT_PUBLIC_EMAILJS_USER_ID;
    const serviceId = process.env.NEXT_PUBLIC_EMAILJS_SERVICE_ID;
    const templateId = process.env.NEXT_PUBLIC_EMAILJS_TEMPLATE_ID;

    if (!userId || !serviceId || !templateId) {
      toast.error("エラー: 送信設定が不完全です。");
      console.error("EmailJS Error: Missing environment variables");
      setIsSending(false);
      return;
    }

    const params = {
      name: data.username,
      email: data.email,
      content: data.content,
    };

    init(userId);

    try {
      await send(serviceId, templateId, params);
      toast.success("お問い合わせありがとうございます!メッセージが送信されました。");
      form.reset();
    } catch (error) {
      console.error("EmailJS send error:", error);
      toast.error("メッセージの送信に失敗しました。時間をおいて再度お試しください。");
    } finally {
      setIsSending(false);
    }
  };

  return (
    <section className="w-full max-w-lg mx-auto p-4 md:p-6">
      <h2 className="text-2xl font-bold mb-6 text-center">お問い合わせ</h2>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
          <FormField
            control={form.control}
            name="username"
            render={({ field }) => (
              <FormItem>
                <FormLabel>お名前</FormLabel>
                <FormControl>
                  <Input placeholder="例: 山田 太郎" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormLabel>メールアドレス</FormLabel>
                <FormControl>
                  <Input type="email" placeholder="例: your.email@example.com" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name="content"
            render={({ field }) => (
              <FormItem>
                <FormLabel>メッセージ</FormLabel>
                <FormControl>
                  <Textarea
                    placeholder="お問い合わせ内容を入力してください (10文字以上)"
                    className="min-h-[120px]"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <Button disabled={isSending} type="submit" className="w-full">
            {isSending ? "送信中..." : "送信する"}
          </Button>
        </form>
      </Form>
    </section>
  );
}

  1. 前提となる環境と準備 (概要)
    この記事のコードを試すには、以下の技術スタックでプロジェクトがセットアップされている必要があります。

Next.js (App Router)
Tailwind CSS (v3 または v4)
shadcn/ui: init 済み、かつ form, input, button, textarea, sonner コンポーネントを追加済み
ライブラリ: react-hook-form, zod, @hookform/resolvers, @emailjs/browser がインストール済み
セットアップ手順の詳細は各ドキュメントをご参照ください。

emailjs の事前準備 (概要)

emailjs
フォームの内容をメールで送信するために、emailjs を利用します。事前に以下の準備が必要です。
emailjs アカウント作成: 無料プランで始められます。
Email Service の追加: Gmailなど、使用するメールサービスを連携し、Service ID を取得します。
Email Template の作成: 送信するメールの件名と本文のテンプレートを作成し、Template ID を取得します。テンプレート内では、フォームの値を {{変数名}} (例: {{name}}, {{email}}, {{content}}) で参照できます。
環境変数の設定: プロジェクトルートに .env.local ファイルを作成し、以下のキーで emailjs の情報を設定します。(YOUR_... の部分を実際の値に置き換えてください)

.env
NEXT_PUBLIC_EMAILJS_USER_ID=YOUR_USER_ID
NEXT_PUBLIC_EMAILJS_SERVICE_ID=YOUR_SERVICE_ID
NEXT_PUBLIC_EMAILJS_TEMPLATE_ID=YOUR_TEMPLATE_ID

準備ができたら、いよいよコンポーネントのコードを見ていきましょう!

2. コンポーネントのコード解説

ファイルの中身を詳しく解説します。

components/ContactSection.tsx
"use client"; // ① クライアントコンポーネント宣言

// ② 必要なモジュールをインポート
import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner"; // 通知用
import { init, send } from "@emailjs/browser"; // emailjs 送信関数
import { // shadcn/ui コンポーネント
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useState } from "react"; // 送信状態管理用
  • "use client";: このコンポーネントが クライアントコンポーネント であることを示します。フォームの入力や状態変化、ボタンクリックなどのインタラクションはブラウザ(クライアント側)で行われるため、useState や useForm といった React フックを使うにはこの宣言が必須です。Next.js の App Router における重要な概念です。

  • インポート: 使用するライブラリやコンポーネントを読み込んでいます。react-hook-form からフォーム管理用のフック、zod からスキーマ定義、@hookform/resolvers から Zod と連携するためのアダプター、sonner からトースト通知関数、@emailjs/browser からメール送信関数、そして shadcn/ui からフォーム関連の UI コンポーネントをインポートしています。

// ③ Zod でバリデーションスキーマを定義
const formSchema = z.object({
  username: z
    .string() // 文字列型
    .min(2, { message: "お名前は2文字以上で入力してください。" }) // 最小2文字
    .max(50, { message: "お名前は50文字以下で入力してください。" }), // 最大50文字
  email: z
    .string()
    .email({ message: "正しいメールアドレスを入力してください。" }), // メール形式チェック
  content: z
    .string()
    .min(10, { message: "メッセージは10文字以上で入力してください。" }) // 最小10文字
    .max(1000, { message: "メッセージは1000文字以下で入力してください。"}), // 最大1000文字
});

// ④ ZodスキーマからTypeScriptの型を生成
type FormType = z.infer<typeof formSchema>;
  • formSchema (Zod スキーマ): ここでフォームの入力ルール (バリデーション) を定義します。zod は非常に直感的にスキーマを記述できます。

  • z.object({...}): フォーム全体のデータ構造をオブジェクトとして定義します。

  • キー (username, email, content) がフォームの各フィールドに対応します。

  • z.string(): そのフィールドが文字列であることを示します。

  • .min(文字数, { message: "..." }): 最小文字数を指定します。第二引数のオブジェクトで、ルール違反時のエラーメッセージをカスタマイズできます。

  • .max(文字数, { message: "..." }): 最大文字数を指定します。

  • .email({ message: "..." }): 入力値が有効なメールアドレス形式かチェックします。

  • このように、各フィールドに連鎖的に(メソッドチェーンで)ルールを追加していきます。日本語でエラーメッセージを設定できるのが便利です。

  • FormType (TypeScript 型): z.infer<typeof formSchema> は Zod の強力な機能の一つです。定義した formSchema から自動的に TypeScript の型 ({ username: string; email: string; content: string; } のような型) を生成してくれます。これにより、後のコードでフォームデータの型補完が効き、型安全性が向上します。

export default function ContactSection() {
  // ⑤ 送信中フラグの状態管理
  const [isSending, setIsSending] = useState(false);

  // ⑥ react-hook-form の初期化と設定
  const form = useForm<FormType>({
    resolver: zodResolver(formSchema), // Zod をバリデーションリゾルバーとして使用
    defaultValues: { // フォームの初期値
      username: "",
      email: "",
      content: "",
    },
  });
  // ... (onSubmit 関数, JSX は後述)
}
  • useState: isSending という名前の state 変数を定義します。これは、メール送信処理中かどうかを示すフラグです。初期値は false(送信中でない)。このフラグを使って、送信中はボタンを無効化したり、表示を「送信中...」に変えたりします。

  • useForm<FormType>: react-hook-form の主要なフックです。ここでフォーム全体の管理を行います。

  • <FormType>: ジェネリクスでフォームデータの型を指定します。先ほど Zod から生成した FormType を使うことで、form オブジェクト(例えば form.control や form.handleSubmit の引数 data)が型安全になります。

  • resolver: zodResolver(formSchema): ここが react-hook-form と Zod を連携させる 重要な部分です。@hookform/resolvers/zod からインポートした zodResolver に、先ほど定義した formSchema を渡します。これにより、フォームの入力値が formSchema のルールに従っているかどうかが自動的にチェックされるようになります。

  • defaultValues: フォームの各フィールドの初期値を設定します。コンポーネントがマウントされたとき、これらの値がフォームに表示されます。

  // ⑦ フォーム送信時の処理 (バリデーション成功後に実行)
  const onSubmit: SubmitHandler<FormType> = async (data: FormType) => {
    setIsSending(true); // 送信処理開始 -> UIを更新 (ボタン無効化など)

    // ⑧ 環境変数から emailjs の ID を取得
    const userId = process.env.NEXT_PUBLIC_EMAILJS_USER_ID;
    const serviceId = process.env.NEXT_PUBLIC_EMAILJS_SERVICE_ID;
    const templateId = process.env.NEXT_PUBLIC_EMAILJS_TEMPLATE_ID;

    // 環境変数の存在チェック
    if (!userId || !serviceId || !templateId) {
      toast.error("エラー: 送信設定が不完全です。"); // ユーザーへの通知
      console.error("EmailJS Error: Missing environment variables"); // 開発者向けログ
      setIsSending(false); // 送信状態を解除
      return; // 処理を中断
    }

    // ⑨ emailjs のテンプレートに渡すパラメータを作成
    // data (引数) にはバリデーション済みのフォームデータが入っている
    const params = {
      name: data.username, // Zod スキーマのキーに対応
      email: data.email,
      content: data.content,
      // emailjs テンプレートで定義した他の変数があればここに追加
    };

    // ⑩ emailjs でメール送信を実行
    init(userId); // User ID で emailjs を初期化 (毎回呼び出すのが推奨)

    try {
      // send 関数でメール送信 (非同期処理)
      await send(serviceId, templateId, params);
      // 成功した場合
      toast.success("お問い合わせありがとうございます!メッセージが送信されました。"); // 成功通知
      form.reset(); // フォームの内容をリセット (defaultValues に戻す)
    } catch (error) {
      // 失敗した場合
      console.error("EmailJS send error:", error); // エラーの詳細をコンソールに出力
      toast.error("メッセージの送信に失敗しました。時間をおいて再度お試しください。"); // 失敗通知
    } finally {
      // 成功・失敗に関わらず最後に実行
      setIsSending(false); // 送信状態を解除 -> UIを元に戻す
    }
  };
  • onSubmit 関数: この関数は、フォームが送信され、かつ zodResolver によるバリデーションが成功した場合のみ 呼び出されます。
  • SubmitHandler<FormType>: react-hook-form が提供する型です。引数 data が自動的に FormType となり、バリデーション済みのフォームデータが渡されます。
  • 環境変数取得: .env.local に設定した emailjs の ID を process.env から取得します。NEXT_PUBLIC_ プレフィックスが付いているため、クライアントサイドのコードで安全にアクセスできます。ID が取得できない場合はエラー処理をして中断します。
  • params オブジェクト: emailjs.send 関数の第三引数に渡すオブジェクトです。キーは emailjs で作成したメールテンプレート内の変数 ({{変数名}}) と一致させる必要があります。値には onSubmit の引数 data (バリデーション済みフォームデータ) を使います。
  • メール送信処理:
    init(userId): emailjs を使う前に User ID で初期化します。
    send(serviceId, templateId, params): 実際のメール送信を行います。これは非同期処理(Promise を返す)なので async/await を使っています。
  • try...catch...finally: 非同期処理のエラーハンドリングと後処理を行います。
    try: send が成功したら、sonner の toast.success で成功メッセージを表示し、form.reset() でフォームの入力内容を defaultValues にリセットします。
  • catch: send が失敗(ネットワークエラー、emailjs 側のエラーなど)したら、console.error でエラーの詳細をログに出力し、toast.error でユーザーに失敗したことを伝えます。
  • finally: 送信処理が成功しても失敗しても、最後に必ず setIsSending(false) を実行し、isSending フラグを元に戻します。これにより、送信ボタンが再び有効になります。
  // ⑪ JSX: フォームのレンダリング
  return (
    // ⑫ 全体を section タグで囲み、スタイルを適用
    <section className="w-full max-w-lg mx-auto p-4 md:p-6">
      <h2 className="text-2xl font-bold mb-6 text-center">お問い合わせ</h2>

      {/* ⑬ shadcn/ui の Form コンポーネント */}
      <Form {...form}>
        {/* ⑭ ネイティブの form 要素。onSubmit に form.handleSubmit を渡す */}
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">

          {/* ⑮ shadcn/ui の FormField コンポーネント (各入力フィールド) */}
          <FormField
            control={form.control} // react-hook-form の control を渡す
            name="username"        // Zod スキーマのキーと一致
            render={({ field }) => ( // ⑯ レンダリング関数
              <FormItem> {/* shadcn/ui: ラベル、入力、メッセージをまとめる */}
                <FormLabel>お名前</FormLabel>
                <FormControl> {/* shadcn/ui: 入力要素をラップ */}
                  {/* shadcn/ui の Input。{...field} で RHF と連携 */}
                  <Input placeholder="例: 山田 太郎" {...field} />
                </FormControl>
                <FormMessage /> {/* shadcn/ui: エラーメッセージ表示エリア */}
              </FormItem>
            )}
          />

          {/* メールアドレスフィールド (同様の構造) */}
          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormLabel>メールアドレス</FormLabel>
                <FormControl>
                  <Input type="email" placeholder="例: your.email@example.com" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />

          {/* メッセージ本文フィールド (Textarea を使用) */}
          <FormField
            control={form.control}
            name="content"
            render={({ field }) => (
              <FormItem>
                <FormLabel>メッセージ</FormLabel>
                <FormControl>
                  <Textarea
                    placeholder="お問い合わせ内容を入力してください (10文字以上)"
                    className="min-h-[120px]" // 見た目の調整
                    {...field} // Input と同様に連携
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />

          {/* ⑰ 送信ボタン */}
          <Button disabled={isSending} type="submit" className="w-full">
            {/* isSending フラグに応じて表示を切り替え */}
            {isSending ? "送信中..." : "送信する"}
          </Button>
        </form>
      </Form>
    </section>
  );
}
  • JSX: コンポーネントの見た目を定義する部分です。

  • <section>: フォーム全体を囲むコンテナです。Tailwind CSS でスタイル(横幅、中央寄せ、余白)を設定しています。

  • <Form {...form}>: shadcn/ui の Form コンポーネントです。react-hook-form の useForm フックから返された form オブジェクトをスプレッド構文 ({...form}) で渡すことで、react-hook-form のコンテキストを内部の FormField などで利用できるようにします。
    <form onSubmit={form.handleSubmit(onSubmit)}>: HTML ネイティブの form 要素です。ポイントは onSubmit プロパティです。

  • form.handleSubmit(): react-hook-form が提供する関数です。これを onSubmit に渡すことで、フォーム送信時に以下の処理が自動的に行われます。

  • event.preventDefault() (ページの再読み込み防止)

  • zodResolver を使ったバリデーションの実行

  • バリデーションが成功した場合のみ、引数で渡した自作の onSubmit 関数(⑦で定義したもの)を、バリデーション済みのデータ (data) を引数にして呼び出す。

  • バリデーションが失敗した場合は、onSubmit 関数は呼び出されず、エラー情報が form オブジェクトに格納されます(FormMessage がそれを表示します)。

  • <FormField>: shadcn/ui のコンポーネントで、ラベル、入力コントロール、エラーメッセージを一つの単位として扱います。react-hook-form と連携するために使います。
    control={form.control}: useForm から取得した control オブジェクトを渡します。これにより、react-hook-form がこのフィールドの状態(値、タッチ状態、エラーなど)を管理できるようになります。

  • name="username": このフィールドがフォームデータのどのキーに対応するかを指定します。Zod スキーマのキー (formSchema のキー) と必ず一致させる必要があります。

  • render={({ field }) => ...}: FormField の中身を実際にレンダリングする関数です (Render Prop パターン)。引数オブジェクトの中にある field が重要です。

  • field: react-hook-form がこのフィールドを制御するために必要なプロパティ (onChange, onBlur, value, name, ref) を含んだオブジェクトです。

  • <Input {...field} /> または <Textarea {...field} />: shadcn/ui の入力コンポーネントに {...field} を渡すだけで、react-hook-form との連携が完了します。入力値の変更やフォーカス喪失が自動的に react-hook-form に伝わり、状態管理やバリデーションが行われます。

  • FormItem, FormLabel, FormControl, FormMessage: これらは shadcn/ui が提供するコンポーネントで、WAI-ARIA に準拠したアクセシブルなフォーム構造を簡単に作るのに役立ちます。FormMessage は、対応する FormField (name で紐づけられる) にバリデーションエラーがある場合に、自動的に Zod スキーマで設定したエラーメッセージを表示してくれます。

  • <Button>: shadcn/ui のボタンコンポーネントです。

  • disabled={isSending}: ⑤ で定義した isSending フラグが true の間(つまりメール送信処理中)は、ボタンをクリックできないようにします。誤って連続送信されるのを防ぎます。
    type="submit": このボタンがフォームを送信するためのボタンであることを示します。クリックされると、親の form 要素の onSubmit (つまり form.handleSubmit(onSubmit)) がトリガーされます。

  • ボタンのテキストも isSending フラグの状態に応じて条件分岐で表示を変えています。

動作確認

開発サーバー (npm run dev or yarn dev) を起動し以下の点を確認してみてください。

  • バリデーションルール(文字数、メール形式)が正しく機能し、エラーメッセージが表示されるか。
  • 正常に入力して送信すると、成功通知が表示され、フォームがリセットされるか。
  • 指定したメールアドレスにメールが届くか。
  • 送信中にボタンが無効化され、「送信中...」と表示されるか。
  • 意図的に送信エラーを起こした場合(例: .env.local の ID を一時的に書き換える)、エラー通知が表示されるか。

まとめ

今回は、Next.js, shadcn/ui, React Hook Form, Zod, emailjs を使ったお問い合わせフォームの実装コードを中心に解説しました。
shadcn/ui の Form, FormField などを使うことで、構造的でアクセシブルなフォーム UI を簡単に構築できました。
Zod で直感的にバリデーションルールを定義し、zodResolver を介して React Hook Form と連携させることで、堅牢な入力チェックを実現しました。
useForm フックがフォームの状態管理を大幅に簡略化し、handleSubmit がバリデーションと送信処理の連携をスムーズにしてくれました。
emailjs を使うことで、サーバーサイドの実装なしにメール送信機能を追加できました。
"use client", 環境変数 (NEXT_PUBLIC_), async/await での非同期処理など、実践的な要素も含まれていました。
これらのライブラリやパターンを理解することで、より複雑なフォームも効率的に開発できるようになるはずです。ぜひ、このコードをベースにカスタマイズして、ご自身のプロジェクトに役立ててください!
Happy Coding! 😊

Discussion