RemixとNext.jsでZod-form-dataを使用して堅牢で型安全なフォームを構築する
Grace Collins
Solutions Engineer · Leapcell

はじめに
Web開発の進化し続ける状況において、ユーザーフレンドリーで信頼性の高いフォームの構築は、依然として基本的な課題です。アプリケーションが複雑になるにつれて、データ整合性の確保、意味のあるユーザーフィードバックの提供、スムーズな開発者体験の維持が最重要となります。従来のほとんどのアプローチでは、クライアントサイド検証、サーバーサイド検証、手動の型アサーションなどを組み合わせて使用しており、断片的でエラーを起こしやすいプロセスにつながっていました。これにより、微妙なバグ、クライアントとサーバー間の不整合、機能構築というよりは型と格闘しているような開発者体験が生じる可能性があります。
RemixやNext.jsのようなモダンなフルスタックフレームワークと、強力なスキーマ検証ライブラリの登場は、説得力のあるソリューションを提供します。特に、zod-form-dataの統合は、フォーム処理に新しいレベルの洗練をもたらし、標準でエンドツーエンドの型安全性と段階的な拡張を可能にします。このアプローチは開発を合理化するだけでなく、Webアプリケーションの堅牢性と信頼性も大幅に向上させます。この記事では、RemixとNext.js内でzod-form-dataを活用して、この望ましい状態を達成する方法を、通常のクライアントサイド検証を超えて、真に型安全で段階的に拡張されたフォーム体験へと進みます。
コアコンポーネントの理解
実装の詳細に入る前に、議論の中心となる主要なテクノロジーと概念を簡単に定義しましょう。
- Remix / Next.js: これらはフルスタックReactフレームワークです。
- Remix: Web標準、サーバーサイドレンダリング(SSR)、ネストされたルーティングを重視し、フォーム送信とデータ変更のための組み込みメカニズムを提供します。そのアクション/ローダーパラダイムは、フォームデータを処理するのに特に適しています。
- Next.js: SSR、静的サイト生成(SSG)、APIルートのような強力な機能を提供し、さまざまなアプリケーションアーキテクチャに柔軟に対応できます。そのAPIルートは、フォーム送信を処理するための優れたバックエンドとして機能します。
- 段階的拡張(Progressive Enhancement): Web開発の戦略であり、すべてのユーザーがアクセスできるコアコンテンツと機能のベースラインを構築し、次に機能が豊富なブラウザのユーザーのためにプレゼンテーションと機能のレイヤーを段階的に追加します。フォームの文脈では、これはJavaScriptが無効になってもフォームが機能するべきであり、JavaScriptが利用可能な場合は(即時検証のような)拡張機能を提供するべきであることを意味します。
- エンドツーエンドの型安全性: ユーザーインターフェース(クライアントサイド)からバックエンド(サーバーサイド)、データベースに至るまで、アプリケーションのすべてのレイヤーでデータ型が一貫して強制され、検証されることを保証します。これにより、型関連のエラーが最小限に抑えられ、コードの保守性が向上し、データの一貫性に関する強力な保証が得られます。
- Zod: TypeScriptファーストのスキーマ宣言および検証ライブラリです。これにより、開発者は任意のデータ構造のスキーマを定義でき、それを使用して受信データを検証し、TypeScript型を推論できます。Zodの強力な推論機能は、エンドツーエンドの型安全性の基盤となります。
zod-form-data:FormDataオブジェクトを特別に処理するZodのプリプロセッサです。これにより、HTMLフォーム経由で送信されたデータを検証および変換するためにZodスキーマを定義でき、ファイルアップロード、チェックボックス、マルチセレクトをうまく処理できます。重要なのは、Zodスキーマに基づいて、FormDataからの文字列値を正しい型(数値、ブール値など)に自動的に変換できることです。
段階的拡張とエンドツーエンドの型安全性の達成
中心的なアイデアは、フォームデータの期待される構造と型を表す、単一の権威あるZodスキーマを定義することです。このスキーマは、クライアントで即時フィードバックを提供するためにも、サーバーで受信した送信を厳密に検証するためにも(両方で)使用されます。zod-form-dataは、このZodスキーマがHTMLフォーム送信からの生のFormDataオブジェクトを直接処理できるようにすることで、ギャップを埋めます。
RemixとNext.jsの両方で、具体的な例を挙げてこれを説明しましょう。
Remixでの例
Remixのaction関数は、フォーム送信を処理するのに自然に適合します。Zodスキーマを一度定義し、アクション内で使用できます。
// app/routes/newsletter.tsx import { ActionFunctionArgs, json } from "@remix-run/node"; import { Form, useActionData } from "@remix-run/react"; import { z } from "zod"; import { zfd } from "zod-form-data"; // 1. フォームデータのZodスキーマを定義 const newsletterSchema = zfd.formData({ email: zfd.text(z.string().email("無効なメールアドレス")), source: zfd.text(z.string().optional()), acceptTerms: zfd.checkbox(), }); type NewsletterData = z.infer<typeof newsletterSchema>; export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData(); try { // 2. スキーマを使用してフォームデータを解析および検証 const data = newsletterSchema.parse(formData); // 3. 検証が成功した場合、データを処理 console.log("ニュースレター登録データ:", data); // 実際のアプリでは、これをデータベースに保存したり、メールを送信したりします。 return json({ success: true, message: "ご登録ありがとうございます!" }); } catch (error) { // 4. 検証が失敗した場合、エラーを返す if (error instanceof z.ZodError) { const errors = error.flatten(); return json({ success: false, errors: errors.fieldErrors }, { status: 400 }); } return json({ success: false, message: "予期しないエラーが発生しました。" }, { status: 500 }); } } export default function NewsletterSignup() { const actionData = useActionData<typeof action>(); return ( <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md"> <h1 className="text-2xl font-bold mb-4">ニュースレターに登録する</h1> <Form method="post" className="space-y-4"> <div> <label htmlFor="email" className="block text-sm font-medium text-gray-700">メール:</label> <input type="email" id="email" name="email" required className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> {actionData?.errors?.email && ( <p className="mt-1 text-sm text-red-600">{actionData.errors.email[0]}</p> )} </div> <div> <label htmlFor="source" className="block text-sm font-medium text-gray-700">どこで知りましたか? (任意)</label> <input type="text" id="source" name="source" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> </div> <div className="flex items-center"> <input type="checkbox" id="acceptTerms" name="acceptTerms" required className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" /> <label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-900"> 利用規約に同意します </label> {actionData?.errors?.acceptTerms && ( <p className="ml-2 text-sm text-red-600">{actionData.errors.acceptTerms[0]}</p> )} </div> <button type="submit" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > 登録 </button> </Form> {actionData?.success && ( <p className="mt-4 text-green-600">{actionData.message}</p> )} {actionData?.success === false && !actionData.errors && ( <p className="mt-4 text-red-600">{actionData.message}</p> )} </div> ); }
説明:
- スキーマ定義:
zfd.formDataを使用してnewsletterSchemaを定義します。zfd.textがテキスト入力に使用され、zfd.checkboxがチェックボックスに使用されていることに注意してください。zfd.checkboxは、チェックボックスの値の存在を正しくブール値に解析します。 - サーバーサイド検証(Remix
action):action関数内で、リクエストからformDataを取得します。newsletterSchema.parse(formData)は、FormDataを定義された型に検証および変換しようとします。検証が失敗すると、ZodErrorがスローされ、これをキャッチして特定のフィールドエラーを返します。 - 段階的拡張: JavaScriptが無効な場合、フォームは直接
actionエンドポイントに送信され、サーバーサイド検証は引き続き機能し、適切なHTTPステータスコードとエラーメッセージを返します。 - クライアントサイドフィードバックと型安全性:
useActionDataは、サーバーからの検証結果をUIに提供します。actionの戻り値の型がわかっているため、actionDataは完全に型付けされており、確実な方法で特定のフィールドのエラーを表示できます。
Next.jsでの例
Next.jsでは、通常APIルートまたはサーバーアクション(Next.js 13.4+で導入)を使用してフォーム送信を処理します。ここではAPIルートを使用した例を示します。これはより広範に適用できます。
// pages/api/newsletter.ts (API Route) import type { NextApiRequest, NextApiResponse } from 'next'; import { z } from 'zod'; import { zfd } from 'zod-form-data'; // 1. フォームデータのZodスキーマを定義(Remixと同じ) const newsletterSchema = zfd.formData({ email: zfd.text(z.string().email("無効なメールアドレス")), source: zfd.text(z.string().optional()), acceptTerms: zfd.checkbox(), }); export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { return res.status(405).json({ message: 'Method Not Allowed' }); } try { // Next.js APIルートでは、application/x-www-form-urlencodedの場合、req.bodyにFormDataが直接表示されません。 // 簡単のためformDataをシミュレートしますが、実際のFormDataを使用するシナリオでは、 // `next-connect`や`formidable`のようなミドルウェアを使用して正しく解析する必要があるかもしれません、 // またはファイルがない場合はクライアントが`application/json`を送信するようにします。 // デモンストレーションのため、必要に応じて`req.body`をシミュレートされた`FormData`オブジェクトに変換するか、 // または単純なオブジェクトのように`req.body`を直接解析すると仮定します。 // FormDataをNext.js APIルートでより堅牢に処理する方法: // 通常、`formidable`や`multer`のようなライブラリを使用してmultipart/form-dataを解析します。 // application/x-www-form-urlencodedの場合、`req.body`は既に解析されています。 // この例では、`req.body`が`zfd.formData`が処理できるようなオブジェクト構造になっていると仮定します。 // 簡単にするため、`req.body`がフォームフィールドを直接表すオブジェクトであり、 // zfdがそれをFormDataのように処理できると仮定します。これは`application/x-www-form-urlencoded`で機能します。 const formDataLikeObject = new FormData(); for (const key in req.body) { // `value`が配列のような場合(複数のチェックボックスまたは選択オプションなど)を処理 if (Array.isArray(req.body[key])) { req.body[key].forEach((item: string) => formDataLikeObject.append(key, item)); } else { formDataLikeObject.append(key, req.body[key]); } } // 2. スキーマを使用してフォームデータを解析および検証 const data = newsletterSchema.parse(formDataLikeObject); // 3. 検証が成功した場合、データを処理 console.log("ニュースレター登録データ:", data); // データベースに保存、メール送信など。 return res.status(200).json({ success: true, message: "ご登録ありがとうございます!" }); } catch (error) { // 4. 検証が失敗した場合、エラーを返す if (error instanceof z.ZodError) { const errors = error.flatten(); return res.status(400).json({ success: false, errors: errors.fieldErrors }); } return res.status(500).json({ success: false, message: "予期しないエラーが発生しました。" }); } }
// pages/newsletter-signup.tsx (Client-Side Page) import { useState } from 'react'; import { z } from 'zod'; // クライアントサイド検証ヒントのためにZodをインポート // 一貫性を保証するため、クライアントサイド検証にも同じスキーマを使用 const newsletterClientSchema = z.object({ email: z.string().email("無効なメールアドレス") , source: z.string().optional(), // 注:zfd.checkboxは'on'/'off'または欠落を暗黙的に処理します。 // 純粋なクライアントサイドZodでは、ブール値を直接チェックします。 acceptTerms: z.boolean().refine(val => val === true, "利用規約に同意する必要があります") , }); export default function NewsletterSignupPage() { const [status, setStatus] = useState<{ success: boolean; message?: string; errors?: Record<string, string[]> } | null>(null); const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); setStatus(null); const formData = new FormData(event.currentTarget); const formObject = Object.fromEntries(formData.entries()); // 即時フィードバックのためのクライアントサイド事前検証 try { newsletterClientSchema.parse({ ...formObject, acceptTerms: formData.get('acceptTerms') === 'on' // チェックボックスの値を変換 }); } catch (error) { if (error instanceof z.ZodError) { setStatus({ success: false, errors: error.flatten().fieldErrors }); return; } } try { const response = await fetch('/api/newsletter', { method: 'POST', // ファイルには`FormData`を直接送信するのが最適ですが、単純なテキストフィールドの場合 // `application/x-www-form-urlencoded`または`application/json`も一般的です。 // この例では、`formData`は間接的に`multipart/form-data`として送信されます。 // 明示的に`application/x-www-form-urlencoded`を希望する場合は、formDataをURLSearchParamsに変換します。 body: formData, }); const data = await response.json(); setStatus(data); } catch (error) { setStatus({ success: false, message: "ネットワークエラー。もう一度お試しください。" }); } }; return ( <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md"> <h1 className="text-2xl font-bold mb-4">ニュースレターに登録する</h1> <form onSubmit={handleSubmit} className="space-y-4"> <div> <label htmlFor="email" className="block text-sm font-medium text-gray-700">メール:</label> <input type="email" id="email" name="email" required className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> {status?.errors?.email && ( <p className="mt-1 text-sm text-red-600">{status.errors.email[0]}</p> )} </div> <div> <label htmlFor="source" className="block text-sm font-medium text-gray-700">どこで知りましたか? (任意)</label> <input type="text" id="source" name="source" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> </div> <div className="flex items-center"> <input type="checkbox" id="acceptTerms" name="acceptTerms" required className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" /> <label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-900"> 利用規約に同意します </label> {status?.errors?.acceptTerms && ( <p className="ml-2 text-sm text-red-600">{status.errors.acceptTerms[0]}</p> )} </div> <button type="submit" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > 登録 </button> </form> {status?.success && ( <p className="mt-4 text-green-600">{status.message}</p> )} {status?.success === false && !status.errors && ( <p className="mt-4 text-red-600">{status.message}</p> )} </div> ); }
説明:
- スキーマ定義:
newsletterSchemaは同一です。これはエンドツーエンドの型安全性にとって重要です。 - サーバーサイド検証(Next.js API Route):
NextApiRequestのreq.bodyは、multipart/form-dataリクエストに対してFormDataオブジェクトを自動的に提供しません。単純なapplication/x-www-form-urlencoded送信の場合、req.bodyはオブジェクトに解析されます。zfd.formDataをシームレスに機能させるために、req.bodyからFormDataオブジェクトを手動で再構築します。実稼働シナリオ(ファイルアップロードなど)では、formidableのようなミドルウェアを使用してmultipart/form-dataストリームを解析し、解析されたフィールドをzfd.formDataに渡すことになります。- エラー処理はRemixと同様で、JSONレスポンスを返します。
- クライアントサイド検証と段階的拡張:
- UIに即時フィードバックを提供するために、クライアントサイド検証に同じ
zスキーマ形状を使用し、チェックボックスの値を適切に変換します。 - フォームが送信されると、
fetchはFormDataをAPIルートに送信します。 - JavaScriptが無効な場合、ブラウザは従来どおりフォーム送信にフォールバックしますが、クライアントサイドルートであるため、Remixアクションのようにフォームが文字通り同じルートに投稿する場合とは同じ「段階的拡張」の方法では機能しません。サーバーアクションなしでNext.jsで真の段階的拡張を行うには、
POSTリクエスト専用のページを使用するか、エラー時のフルページリロードを使用する必要があるかもしれません。Next.jsサーバーアクションはこれを劇的に簡略化し、Remixと非常に似た動作をします。 - クライアントサイド検証は即時のユーザーフィードバックを提供し、サーバーサイド検証は最終的な保護措置として機能します。
- UIに即時フィードバックを提供するために、クライアントサイド検証に同じ
結論
zod-form-dataをZodと統合することにより、RemixとNext.jsでのフォーム処理のための堅牢なパターンを確立します。このアプローチはフォームスキーマ定義を一元化し、UIからバックエンドまでのエンドツーエンドの型安全性を保証し、段階的拡張を本質的にサポートします。開発者は、ボイラープレートコードの削減、コンパイル時のエラーチェック、アプリケーション全体での一貫した検証ストーリーから恩恵を受け、最終的にはより信頼性が高く保守しやすいフォームにつながります。この強力な組み合わせは、開発者体験とフォームとのユーザーインタラクションの質を大幅に向上させます。

