型エラーや不正データに悩むエンジニア向けに、Next.js + TypeScriptでの問い合わせフォーム実装を実案件ベースで解説。Server ActionsとZodを組み合わせた型安全な設計を詳しく紹介します。
「フォームのバリデーション、どこまで書けばいいの?」
こんな悩みはありませんか?
- サーバー側とクライアント側でバリデーションロジックが二重管理になってしまっている
- フォームの送信データに予期しない型が混入してビルドエラーが頻発する
anyを使ってとりあえず動かしているが、後で見直すたびに何の値か分からなくなる- React Hook Form を導入したものの、Server Actions との連携方法が分からず手が止まっている
これはフロントエンド開発の現場でよく耳にする声です。特にNext.js 13以降でApp Routerが登場してから、フォーム処理のベストプラクティスが大きく変わり、「以前の方法が通じなくなった」と混乱しているエンジニアも少なくありません。
この記事では、Fivenineが実際の案件で採用している「Next.js + TypeScript + Zod + React Hook Form」の組み合わせを、実装の意図も含めて丁寧に解説します。読み終えたあとには、型安全なフォーム設計の全体像が掴めるようになっています。
なぜ「型安全なフォーム」が重要なのか
フォームは一見シンプルに見えますが、実はアプリケーションの中でもっとも多くの不正入力が流入する場所です。ユーザーが意図せず入力する半角・全角の混在、空白文字の混入、予期しないフィールドの欠落——これらをサーバー側だけで防ごうとすると、デバッグが難しい実行時エラーに悩まされます。
TypeScriptを使っているからといって、それだけでは外部から来るデータ(=フォームの送信値)は型安全になりません。TypeScriptの型はコンパイル時にしか機能しないため、ランタイムで流れ込んでくる文字列や数値は「信頼できないデータ」として扱う必要があります。
ここで鍵になるのが Zodによるスキーマバリデーション です。Zodはランタイムで型の検証を行い、TypeScriptの型推論とも完全に連携します。つまり「一度スキーマを定義すれば、型定義とバリデーションを同時に手に入れられる」という強力な仕組みが実現します。
flowchart TD
A[ユーザーがフォーム入力] --> B[React Hook Form でクライアントバリデーション]
B -->|エラーあり| C[UIにエラー表示]
B -->|OK| D[Server Action に送信]
D --> E[Zod でサーバーサイドバリデーション]
E -->|エラーあり| F[エラーをクライアントに返す]
E -->|OK| G[メール送信 / DB保存]
G --> H[完了ページへリダイレクト]実案件で採用した構成と実装手順
背景:神奈川県内の製造業クライアントの事例
あるクライアントでは、既存のWordPressサイトから問い合わせフォームだけをNext.jsに切り出して再実装するプロジェクトを受注しました。課題は「スパム送信が多発していること」と「送信後の処理ロジックが管理しにくいこと」の2点でした。
以前の実装はJavaScript(素のJS)でバリデーションを行い、PHPのメール送信スクリプトに投げる構成でしたが、型の保証が一切なく、不正なデータが混入しても気づけない状態でした。この課題を解決するために、Next.js App Router + TypeScript + Zod の構成を選択し、開発期間2週間で安定した動作を実現しました。
Step 1: Zodでスキーマを定義する
まずフォームのデータ構造をZodスキーマとして一元管理します。このスキーマが「唯一の真実(Single Source of Truth)」となります。
// lib/schema/contact.ts
import { z } from 'zod';
export const contactSchema = z.object({
name: z
.string()
.min(1, 'お名前を入力してください')
.max(50, 'お名前は50文字以内で入力してください'),
email: z
.string()
.email('正しいメールアドレスを入力してください'),
phone: z
.string()
.regex(/^[0-9-+()\s]*$/, '電話番号の形式が正しくありません')
.optional(),
message: z
.string()
.min(10, 'お問い合わせ内容は10文字以上入力してください')
.max(1000, 'お問い合わせ内容は1000文字以内で入力してください'),
});
// TypeScriptの型をスキーマから自動生成
export type ContactFormData = z.infer<typeof contactSchema>;
z.infer<typeof contactSchema> の一行で型定義が自動生成されます。スキーマを更新すれば型定義も自動的に追従するため、「バリデーションと型の定義がズレていた」という古典的なバグが構造的に発生しなくなります。
Step 2: Server Actionを実装する
// app/actions/contact.ts
'use server';
import { contactSchema } from '@/lib/schema/contact';
import { z } from 'zod';
type ActionResult =
| { success: true }
| { success: false; errors: z.ZodFormattedError<ContactFormData> };
export async function submitContact(
data: unknown
): Promise<ActionResult> {
// ランタイムでスキーマ検証
const parsed = contactSchema.safeParse(data);
if (!parsed.success) {
return {
success: false,
errors: parsed.error.format(),
};
}
// ここからは parsed.data が型安全に使える
const { name, email, message } = parsed.data;
// メール送信処理(nodemailerなど)
await sendEmail({ name, email, message });
return { success: true };
}
safeParse を使うことで、バリデーション失敗時も例外ではなくオブジェクトとして結果が返るため、エラーハンドリングが格段に書きやすくなります。
Step 3: React Hook FormとZodを連携する
// components/ContactForm.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { contactSchema, ContactFormData } from '@/lib/schema/contact';
import { submitContact } from '@/app/actions/contact';
import { useState } from 'react';
export function ContactForm() {
const [isSubmitted, setIsSubmitted] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema), // スキーマをそのまま渡すだけ
});
const onSubmit = async (data: ContactFormData) => {
const result = await submitContact(data);
if (result.success) {
setIsSubmitted(true);
}
};
if (isSubmitted) {
return <p>お問い合わせを受け付けました。担当者よりご連絡いたします。</p>;
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">お名前 *</label>
<input id="name" {...register('name')} />
{errors.name && <p>{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">メールアドレス *</label>
<input id="email" type="email" {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<label htmlFor="message">お問い合わせ内容 *</label>
<textarea id="message" {...register('message')} />
{errors.message && <p>{errors.message.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '送信中...' : '送信する'}
</button>
</form>
);
}
zodResolver(contactSchema) の一行を渡すだけで、React Hook FormがZodスキーマに基づいてクライアントサイドのバリデーションを自動で行います。別途バリデーションルールを書く必要はありません。
よくある失敗パターンと対処法
実案件でも何度か踏んだ落とし穴を正直にお伝えします。
失敗1:Server Action に渡す前に型アサーションを使う
data as ContactFormData のようなキャストを使ってしまうと、TypeScriptの型チェックを騙しているだけでランタイムの安全性はゼロです。必ず safeParse を通してください。
失敗2:スキーマをクライアントとサーバーで別々に定義する
「クライアント用バリデーション」と「サーバー用バリデーション」を別ファイルで管理しているケースをよく見かけますが、両者が徐々にズレていき、片方だけで通過するデータが生まれます。lib/schema/ に一元化するのが鉄則です。
失敗3:エラーメッセージを日本語化し忘れる
Zodのデフォルトエラーメッセージは英語です。本番環境でユーザーに「Required」と表示されてしまったケースが実際にありました。スキーマ定義時にメッセージを必ず日本語で記述する習慣をつけてください。
失敗4:送信中の二重送信対策を忘れる
isSubmitting フラグを使ってボタンを disabled にする処理は、シンプルですが必須です。二重送信が発生するとメールが複数回送られ、クライアントからの信頼を失います。
Web制作・UI改善もお任せください
フロントエンド開発
モダンな技術で、使いやすく美しいWebサイトを実現します
※ 通常1営業日以内にご返信します
まとめと次のステップ
今回紹介した構成のポイントは「スキーマを中心に置いて、型とバリデーションを一元管理する」という設計思想です。ZodスキーマはTypeScriptの型定義にもなり、クライアントのリアルタイムバリデーションにもなり、サーバーのランタイム検証にもなる——この「一石三鳥」の仕組みが、Next.js + TypeScriptの組み合わせを強力にしている理由です。
自社サイトへの導入を検討している方は、まず小さなフォームから試してみることをおすすめします。全体のアーキテクチャを変える必要はなく、一つのフォームコンポーネントから始められます。
Fivenineでは、既存サイトへの問い合わせフォーム単体の実装から、Next.jsを活用した大規模なリプレイスまで幅広く対応しています。「自社の場合はどう設計すればいいか分からない」という段階からでも、ぜひお気軽にご相談ください。