【Next.js】お問い合わせフォームを作成する|簡単なToDoアプリの作成

Next.jsを使い、実践的なお問い合わせフォームを作成する方法を解説します。入力値の検証にはzodライブラリを使用し、サーバー側の処理はAPI Routeで実装します。nodemailerを導入して、管理者への通知とユーザーへの自動返信メールを送信する仕組みを学び、Webアプリケーション開発の一連の流れを体験できます。

作成日: 更新日:

開発環境

  • 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
  • nodemailer: 6.10.1
  • @types/nodemailer: 6.4.17

サンプルコード

/package.json

/package.json
1     "bcrypt": "^6.0.0",
2     "next": "15.3.2",
3     "next-auth": "^4.24.11",
4+    "nodemailer": "^6.10.1",
5     "prisma": "^6.8.2",
6     "react": "^19.0.0",
7     "react-dom": "^19.0.0",
8     "@tailwindcss/postcss": "^4",
9     "@types/bcrypt": "^5.0.2",
10     "@types/node": "^20",
11+    "@types/nodemailer": "^6.4.17",
12     "@types/react": "^19",
13     "@types/react-dom": "^19",
14     "eslint": "^9",
15

/src/app/api/contact/route.ts

/src/app/api/contact/route.ts
1+import { NextResponse } from 'next/server';
2+import nodemailer from 'nodemailer';
3+
4+// 管理者宛の本文
5+function buildAdminMessage(name: string, email: string, message: string) {
6+    return `新しいお問い合わせがありました:
7+
8+【お名前】${name}
9+【メールアドレス】${email}
10+【メッセージ】
11+${message}
12+`;
13+}
14+
15+// お客様宛の自動返信メール本文
16+function buildUserAutoReply(name: string, email: string, message: string) {
17+    return `${name} 様
18+
19+この度はお問い合わせいただき、誠にありがとうございます。
20+以下の内容で受け付けいたしました。
21+
22+----------------------------------
23+【お名前】${name}
24+【メールアドレス】${email}
25+【メッセージ】
26+${message}
27+----------------------------------
28+
29+内容を確認の上、担当者よりご連絡いたしますので、今しばらくお待ちください。
30+
31+`;
32+}
33+
34+export async function POST(req: Request) {
35+    const { name, email, message } = await req.json();
36+
37+    if (!name || !email || !message) {
38+        return NextResponse.json(
39+            {
40+                status: 'error',
41+                message: 'すべての項目を入力してください'
42+            },
43+            {
44+                status: 400
45+            }
46+        );
47+    }
48+
49+    try {
50+        const transporter = nodemailer.createTransport({
51+            host: process.env.SMTP_HOST,
52+            port: Number(process.env.SMTP_PORT),
53+            secure: false,
54+            auth: {
55+                user: process.env.SMTP_USER,
56+                pass: process.env.SMTP_PASS,
57+            },
58+        });
59+
60+        // 管理者に通知
61+        await transporter.sendMail({
62+            from: `"お問い合わせフォーム" <${process.env.SMTP_USER}>`,
63+            to: process.env.CONTACT_TO!,
64+            subject: '【新着】お問い合わせが届きました',
65+            text: buildAdminMessage(name, email, message),
66+        });
67+
68+        // お客様に自動返信
69+        await transporter.sendMail({
70+            from: `"サポートチーム" <${process.env.SMTP_USER}>`,
71+            to: email,
72+            subject: '【自動返信】お問い合わせありがとうございます',
73+            text: buildUserAutoReply(name, email, message),
74+        });
75+
76+        return NextResponse.json(
77+            {
78+                status: 'success',
79+                message: 'お問い合わせを送信しました。ありがとうございました。'
80+            },
81+            {
82+                status: 200
83+            }
84+        );
85+    } catch (err) {
86+        return NextResponse.json(
87+            {
88+                status: 'error',
89+                message: 'メール送信に失敗しました。'
90+            },
91+            {
92+                status: 500
93+            }
94+        );
95+    }
96+}
97

/src/app/components/Header.tsx

/src/app/components/Header.tsx
1                             </Link>
2                         </>
3                     )}
4+                    <Link href="/contact" className="hover:underline cursor-pointer">
5+                        お問い合わせ
6+                    </Link>
7                 </nav>
8             </div>
9 
10                             </Link>
11                         </>
12                     )}
13+                    <Link
14+                        href="/contact"
15+                        onClick={() => setMenuOpen(false)}
16+                        className="block py-2 hover:underline cursor-pointer"
17+                    >
18+                        お問い合わせ
19+                    </Link>
20                 </nav>
21             )}
22         </header>
23

/src/app/contact/page.tsx

/src/app/contact/page.tsx
1+'use client';
2+
3+import { useState } from 'react';
4+import { useFlashMessage } from '@/app/context/FlashMessageContext';
5+import { z } from 'zod';
6+
7+const schema = z.object({
8+    name: z.string().min(2, { message: '名前は2文字以上で入力してください' }),
9+    email: z.string().email({ message: '正しいメールアドレスを入力してください' }),
10+    message: z.string().min(10, { message: '10文字以上入力してください' }),
11+});
12+
13+export default function ContactPage() {
14+    const [form, setForm] = useState({ name: '', email: '', message: '' });
15+    const [errors, setErrors] = useState<{ name?: string; email?: string; message?: string }>({});
16+    const { showMessage } = useFlashMessage();
17+
18+    const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
19+        setForm({ ...form, [e.target.name]: e.target.value });
20+    };
21+
22+    const handleSubmit = async (e: React.FormEvent) => {
23+        e.preventDefault();
24+        setErrors({});
25+
26+        const result = schema.safeParse(form);
27+        if (!result.success) {
28+            const fieldErrors: { name?: string; email?: string; message?: string } = {};
29+            result.error.errors.forEach(({ path, message }) => {
30+                if (path[0] === 'name') fieldErrors.name = message;
31+                if (path[0] === 'email') fieldErrors.email = message;
32+                if (path[0] === 'message') fieldErrors.message = message;
33+            });
34+            setErrors(fieldErrors);
35+            return;
36+        }
37+
38+        try {
39+            const res = await fetch('/api/contact', {
40+                method: 'POST',
41+                headers: { 'Content-Type': 'application/json' },
42+                body: JSON.stringify(form),
43+            });
44+
45+            const data = await res.json();
46+            if (data.status === 'success') {
47+                showMessage(data.message, 'success');
48+                setForm({ name: '', email: '', message: '' });
49+            } else {
50+                showMessage(data.message || '送信に失敗しました。', 'error');
51+            }
52+        } catch (err) {
53+            showMessage('エラーが発生しました。もう一度お試しください。', 'error');
54+        }
55+    };
56+
57+    return (
58+        <div className="flex items-center justify-center min-h-screen bg-gray-100">
59+            <form
60+                onSubmit={handleSubmit}
61+                className="bg-white p-8 rounded shadow-md w-full max-w-md"
62+                noValidate
63+            >
64+                <h2 className="text-2xl font-bold mb-6 text-center">お問い合わせ</h2>
65+
66+                {/* 名前 */}
67+                <div className="mb-4">
68+                    <label className="block text-gray-700 mb-2">お名前</label>
69+                    <input
70+                        type="text"
71+                        name="name"
72+                        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'
73+                            }`}
74+                        value={form.name}
75+                        onChange={handleChange}
76+                        placeholder="山田 太郎"
77+                    />
78+                    {errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
79+                </div>
80+
81+                {/* メール */}
82+                <div className="mb-4">
83+                    <label className="block text-gray-700 mb-2">メールアドレス</label>
84+                    <input
85+                        type="email"
86+                        name="email"
87+                        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'
88+                            }`}
89+                        value={form.email}
90+                        onChange={handleChange}
91+                        placeholder="example@example.com"
92+                    />
93+                    {errors.email && <p className="text-red-500 text-sm mt-1">{errors.email}</p>}
94+                </div>
95+
96+                {/* メッセージ */}
97+                <div className="mb-6">
98+                    <label className="block text-gray-700 mb-2">メッセージ</label>
99+                    <textarea
100+                        name="message"
101+                        rows={5}
102+                        className={`w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 ${errors.message ? 'border-red-500 focus:ring-red-400' : 'focus:ring-blue-400'
103+                            }`}
104+                        value={form.message}
105+                        onChange={handleChange}
106+                        placeholder="お問い合わせ内容を入力してください"
107+                    />
108+                    {errors.message && <p className="text-red-500 text-sm mt-1">{errors.message}</p>}
109+                </div>
110+
111+                <button
112+                    type="submit"
113+                    className="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 transition"
114+                >
115+                    送信
116+                </button>
117+            </form>
118+        </div>
119+    );
120+}
121

コード解説

変更点: メール送信用ライブラリの追加

/package.json
1+    "nodemailer": "^6.10.1",
2+    "@types/nodemailer": "^6.4.17",

これは、プロジェクトに新しい機能を追加するための「部品」(ライブラリ)をインストールした記録です。 nodemailerは、Node.js(サーバーサイドのJavaScript環境)でメールを簡単に送信するための非常に人気のあるライブラリです。お問い合わせフォームからメールを送る心臓部となります。 @types/nodemailerは、nodemailerをTypeScriptという言語で安全に使うための「型定義ファイル」です。これにより、コードを書く際にエディタが関数の使い方を補完してくれたり、間違いを指摘してくれたりするようになります。

変更点: APIルートの作成とメール本文生成の準備

/src/app/api/contact/route.ts
1+import { NextResponse } from 'next/server';
2+import nodemailer from 'nodemailer';
3+
4+// 管理者宛の本文
5+function buildAdminMessage(name: string, email: string, message: string) {
6+    return `...`;
7+}
8+
9+// お客様宛の自動返信メール本文
10+function buildUserAutoReply(name: string, email: string, message: string) {
11+    return `...`;
12+}

ここでは、お問い合わせフォームのデータを受け取って処理するサーバー側のプログラム(APIルート)を作成しています。 Next.jsでは、app/apiディレクトリの中にroute.tsという名前でファイルを作成すると、それがAPIのエンドポイントになります。 まず、必要な部品をimportしています。NextResponseはAPIからフロントエンドへ返事を返すために、nodemailerはメールを送信するために使います。 次に、buildAdminMessagebuildUserAutoReplyという2つの関数を定義しています。これらは、それぞれ管理者宛とユーザーへの自動返信用のメール本文を生成する関数です。名前やメッセージといった可変の情報を埋め込めるようにすることで、同じ文章を何度も書く手間を省き、コードを整理しています。

変更点: POSTリクエストを受け取り、フォームデータを処理する関数の定義

/src/app/api/contact/route.ts
1+export async function POST(req: Request) {
2+    const { name, email, message } = await req.json();
3+
4+    if (!name || !email || !message) {
5+        return NextResponse.json(
6+            {
7+                status: 'error',
8+                message: 'すべての項目を入力してください'
9+            },
10+            {
11+                status: 400
12+            }
13+        );
14+    }
15+
16+    // ...
17+}

export async function POST(req: Request)という記述で、HTTPのPOSTメソッドでこのAPIにリクエストが来たときに実行される処理を定義しています。 const { name, email, message } = await req.json();の部分で、フロントエンドから送られてきたJSON形式のデータ(お問い合わせ内容)を受け取り、name, email, messageという変数にそれぞれ格納しています。 その後のif文では、受け取ったデータが空でないかという簡単なチェック(バリデーション)を行っています。もし必須項目が1つでも欠けていれば、「すべての項目を入力してください」というエラーメッセージをフロントエンドに返して、処理を中断します。

変更点: nodemailerを使ったメール送信サーバーへの接続設定

/src/app/api/contact/route.ts
1+    try {
2+        const transporter = nodemailer.createTransport({
3+            host: process.env.SMTP_HOST,
4+            port: Number(process.env.SMTP_PORT),
5+            secure: false,
6+            auth: {
7+                user: process.env.SMTP_USER,
8+                pass: process.env.SMTP_PASS,
9+            },
10+        });
11+
12+        // ...
13+    } catch (err) {
14+        // ...
15+    }

ここでは、実際にメールを送信するための準備をしています。 nodemailer.createTransportメソッドを使って、メール送信を行うための「輸送係(transporter)」を作成します。この輸送係に、どのメールサーバー(SMTPサーバー)を使ってメールを送るのか、という設定情報を渡しています。 host(サーバー名)、port(ポート番号)、auth(認証情報であるユーザー名とパスワード)といった重要な情報は、process.env.変数名という形で環境変数から読み込んでいます。これにより、パスワードなどの機密情報をコードに直接書き込むことを避け、安全性を高めています。 また、一連のメール送信処理はエラーが発生する可能性があるので、try...catch構文で囲んでいます。tryブロック内の処理で何か問題が起きれば、プログラムが停止することなくcatchブロックの処理に移ります。

変更点: 管理者への通知メール送信

/src/app/api/contact/route.ts
1+        // 管理者に通知
2+        await transporter.sendMail({
3+            from: `"お問い合わせフォーム" <${process.env.SMTP_USER}>`,
4+            to: process.env.CONTACT_TO!,
5+            subject: '【新着】お問い合わせが届きました',
6+            text: buildAdminMessage(name, email, message),
7+        });

先ほど設定した「輸送係(transporter)」を使って、実際にメールを送信する処理です。 transporter.sendMailメソッドを呼び出し、引数にメールの詳細情報をオブジェクト形式で渡します。

  • from: 送信者の名前とメールアドレス
  • to: 宛先(ここでは環境変数で指定した管理者のアドレス)
  • subject: メールの件名
  • text: メールの本文(最初に定義したbuildAdminMessage関数で生成) awaitキーワードが付いているのは、メール送信処理が完了するまで次の処理に進まないようにするためです。

変更点: ユーザーへの自動返信メール送信

/src/app/api/contact/route.ts
1+        // お客様に自動返信
2+        await transporter.sendMail({
3+            from: `"サポートチーム" <${process.env.SMTP_USER}>`,
4+            to: email,
5+            subject: '【自動返信】お問い合わせありがとうございます',
6+            text: buildUserAutoReply(name, email, message),
7+        });

管理者への通知と同様に、今度はお問い合わせをしてくれたユーザーに対して自動返信メールを送信しています。 toには、フォームに入力されたユーザーのメールアドレス(email変数)を指定している点が異なります。件名や本文も、ユーザー向けのbuildUserAutoReply関数を使って生成したものに変わっています。 これにより、ユーザーは自分の問い合わせが正しく受け付けられたことをすぐに確認でき、安心感を得られます。

変更点: メール送信処理の成功・失敗に応じたレスポンス

/src/app/api/contact/route.ts
1+        return NextResponse.json(
2+            {
3+                status: 'success',
4+                message: 'お問い合わせを送信しました。ありがとうございました。'
5+            },
6+            {
7+                status: 200
8+            }
9+        );
10+    } catch (err) {
11+        return NextResponse.json(
12+            {
13+                status: 'error',
14+                message: 'メール送信に失敗しました。'
15+            },
16+            {
17+                status: 500
18+            }
19+        );
20+    }

メール送信処理の最後に、その結果をフロントエンドに返しています。 tryブロック内の処理がすべて無事に完了した場合(管理者とユーザーの両方にメールが送れた場合)、status: 'success'という情報と成功メッセージをJSON形式で返します。このとき、HTTPステータスコードは200(成功)です。 もしtryブロックの途中でエラーが発生してcatchブロックに処理が移った場合、status: 'error'という情報とエラーメッセージを返します。このときのHTTPステータスコードは500(サーバー内部エラー)です。 フロントエンド側ではこのレスポンスを受け取り、statusの値に応じてユーザーに「成功しました」や「失敗しました」といったメッセージを表示します。

変更点: ヘッダーにお問い合わせページへのリンクを追加

/src/app/components/Header.tsx
1+                    <Link href="/contact" className="hover:underline cursor-pointer">
2+                        お問い合わせ
3+                    </Link>
4//...
5+                    <Link
6+                        href="/contact"
7+                        onClick={() => setMenuOpen(false)}
8+                        className="block py-2 hover:underline cursor-pointer"
9+                    >
10+                        お問い合わせ
11+                    </Link>

これは、ウェブサイトの全ページ共通で表示されるヘッダー部分に、新しく作成したお問い合わせページへのリンクを追加している箇所です。 Next.jsのLinkコンポーネントを使用することで、ページ全体を再読み込みすることなく、高速にページ遷移ができます。href="/contact"と指定することで、/contactというURL(お問い合わせページ)へ移動するようになります。 コードが2箇所に追加されているのは、PCなどで画面幅が広いとき用のナビゲーションと、スマートフォンなどで表示されるハンバーガーメニュー内のナビゲーションの両方に対応するためです。

変更点: お問い合わせフォームページのクライアントコンポーネント化と準備

/src/app/contact/page.tsx
1+'use client';
2+
3+import { useState } from 'react';
4+import { useFlashMessage } from '@/app/context/FlashMessageContext';
5+import { z } from 'zod';

ここから、ユーザーが直接目にするお問い合わせフォームのページ(フロントエンド)の実装が始まります。 ファイルの先頭に'use client';と記述することで、このファイルが「クライアントコンポーネント」であることを宣言しています。Next.jsでは、ユーザーの操作に応じて画面を動的に変更したり、状態を管理したりするコンポーネントは、このようにクライアントコンポーネントとして定義する必要があります。 import文では、このページで使う様々な部品を読み込んでいます。

  • useState: ユーザーが入力した内容などを記憶・管理する(状態管理)ためのReactの機能(フック)。
  • useFlashMessage: 送信成功・失敗などのメッセージを画面上部に一時的に表示するための自作コンポーネント。
  • z: 入力値が正しい形式か(例:メールアドレスの形式になっているか)を検証(バリデーション)するためのzodライブラリ。

変更点: zodライブラリによるバリデーションスキーマの定義

/src/app/contact/page.tsx
1+const schema = z.object({
2+    name: z.string().min(2, { message: '名前は2文字以上で入力してください' }),
3+    email: z.string().email({ message: '正しいメールアドレスを入力してください' }),
4+    message: z.string().min(10, { message: '10文字以上入力してください' }),
5+});

zodというライブラリを使って、フォームの入力値に対するルール(スキーマ)を定義しています。これにより、サーバーにデータを送る前に、ブラウザ側で入力内容が正しいかどうかをチェックできます。

  • name: 文字列(string)であり、かつ2文字以上(min(2))でなければならない。
  • email: 文字列であり、かつメールアドレスの形式(email())でなければならない。
  • message: 文字列であり、かつ10文字以上(min(10))でなければならない。 それぞれのルールに違反した場合に表示するエラーメッセージも、ここで一緒に定義しています。この仕組みにより、不正なデータが送信されるのを防ぎ、ユーザーにも親切なフィードバックを提供できます。

変更点: useStateフックによるフォームの状態管理

/src/app/contact/page.tsx
1+export default function ContactPage() {
2+    const [form, setForm] = useState({ name: '', email: '', message: '' });
3+    const [errors, setErrors] = useState<{ name?: string; email?: string; message?: string }>({});
4+    const { showMessage } = useFlashMessage();
5+
6+    const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
7+        setForm({ ...form, [e.target.name]: e.target.value });
8+    };
9+    // ...
10+}

ReactのuseStateフックを使い、このコンポーネントが持つ「状態」を定義しています。

  • formsetForm: フォームの各入力欄(名前、メール、メッセージ)の値を保持するための状態です。setForm関数を使ってこの状態を更新します。
  • errorssetErrors: バリデーションチェックで発生したエラーメッセージを保持するための状態です。setErrors関数で更新します。 handleChange関数は、入力欄に文字が入力されるたびに呼び出されます。そして、setFormを使ってformの状態をリアルタイムに更新しています。これにより、Reactは入力値の変更を検知し、画面を再描画できます。

変更点: フォーム送信時のバリデーションとAPI通信処理

/src/app/contact/page.tsx
1+    const handleSubmit = async (e: React.FormEvent) => {
2+        e.preventDefault();
3+        setErrors({});
4+
5+        const result = schema.safeParse(form);
6+        if (!result.success) {
7+            // ...エラー処理
8+            return;
9+        }
10+
11+        try {
12+            const res = await fetch('/api/contact', {
13+                method: 'POST',
14+                headers: { 'Content-Type': 'application/json' },
15+                body: JSON.stringify(form),
16+            });
17+
18+            const data = await res.json();
19+            if (data.status === 'success') {
20+                // ...成功処理
21+            } else {
22+                // ...失敗処理
23+            }
24+        } catch (err) {
25+            // ...通信エラー処理
26+        }
27+    };

handleSubmitは、送信ボタンがクリックされたときに実行される、このフォームの核となる関数です。 処理の流れは以下の通りです。

  1. e.preventDefault(): フォーム送信時にページがリロードされるというブラウザの標準的な動作をキャンセルします。
  2. setErrors({}): 前回のバリデーションエラーが残らないように、エラー状態を一度リセットします。
  3. schema.safeParse(form): zodで定義したスキーマを使って、現在のフォーム入力値(form状態)を検証します。
  4. if (!result.success): 検証に失敗した場合、結果からエラーメッセージを取り出してerrors状態にセットし、処理を中断します。
  5. fetch('/api/contact', ...): 検証に成功した場合、fetch APIを使って、先ほど作成したサーバー側のAPI (/api/contact) にデータを送信します。method: 'POST'でPOSTリクエストであることを示し、bodyにフォームのデータをJSON形式に変換して含めます。
  6. res.json(): APIからの返事(成功か失敗か)を受け取ります。
  7. if (data.status === 'success'): 返事の内容に応じて、showMessage関数を使い、ユーザーに結果を通知します。

変更点: Reactコンポーネントによるお問い合わせフォームのUI実装

/src/app/contact/page.tsx
1+                {/* 名前 */}
2+                <div className="mb-4">
3+                    <label className="block text-gray-700 mb-2">お名前</label>
4+                    <input
5+                        type="text"
6+                        name="name"
7+                        className={`w-full ... ${errors.name ? 'border-red-500' : '...'} ...`}
8+                        value={form.name}
9+                        onChange={handleChange}
10+                        placeholder="山田 太郎"
11+                    />
12+                    {errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
13+                </div>
14+
15+                {/* ...メールとメッセージも同様... */}

これは、フォームの見た目(UI)を定義している部分で、JSXという構文で書かれています。

  • value={form.name}: input要素の表示内容を、Reactの状態であるform.nameと連動させています。
  • onChange={handleChange}: input要素の内容が変更されるたびにhandleChange関数が呼び出され、Reactの状態が更新されます。このように、UIの操作と状態が双方向に結びついていることを「制御されたコンポーネント」と呼びます。
  • className={...}: errors.nameが存在するかどうか(三項演算子)によって、入力欄の枠線の色を赤く変えるなど、動的にスタイルを切り替えています。
  • {errors.name && <p>...</p>}: errors.nameにエラーメッセージが存在する場合にのみ、そのメッセージを<p>タグで画面に表示します。これは「条件付きレンダリング」と呼ばれるテクニックです。

おわりに

今回は、Next.jsを使い、実践的なお問い合わせフォームを作成しました。フロントエンドではzodライブラリで入力値を検証し、バックエンドではAPI Routeでデータを受け取りました。サーバー側の処理ではnodemailerを導入し、管理者への通知とユーザーへの自動返信メールを送信する機能を実装しました。この一連の流れを通して、Webアプリケーション開発におけるフロントエンドとバックエンドの連携を具体的に学んでいただけたかと思います。

関連コンテンツ

関連IT用語