Building Robust and Type-Safe Forms with Zod-form-data in Remix and Next.js
Grace Collins
Solutions Engineer · Leapcell

Introduction
In the ever-evolving landscape of web development, building user-friendly and reliable forms remains a fundamental challenge. As applications grow in complexity, ensuring data integrity, providing meaningful user feedback, and maintaining a smooth developer experience become paramount. Traditional approaches often involve juggling client-side validation, server-side validation, and manual type assertions, leading to a fragmented and error-prone process. This can result in subtle bugs, inconsistencies between client and server, and a developer experience that feels more like wrestling with types than building features.
The advent of modern full-stack frameworks like Remix and Next.js, coupled with powerful schema validation libraries, offers a compelling solution. Specifically, integrating zod-form-data brings a new level of sophistication to form handling, enabling end-to-end type safety and progressive enhancement out-of-the-box. This approach not only streamlines development but also significantly enhances the robustness and reliability of your web applications. This article delves into how we can leverage zod-form-data within Remix and Next.js to achieve this desirable state, moving beyond the usual client-side validation to a truly type-safe and progressively enhanced form experience.
Understanding the Core Components
Before diving into the implementation details, let's briefly define the key technologies and concepts central to our discussion:
- Remix / Next.js: These are full-stack React frameworks.
- Remix: Emphasizes web standards, server-side rendering (SSR), and nested routing, providing built-in mechanisms for form submissions and data mutations. Its action/loader paradigm is particularly well-suited for handling form data.
- Next.js: Offers powerful features like SSR, static site generation (SSG), and API routes, making it versatile for various application architectures. Its API routes serve as an excellent backend for handling form submissions.
- Progressive Enhancement: A strategy for web development that involves building a baseline of core content and functionality accessible to all users, then progressively adding layers of presentation and functionality for users of more capable browsers. In the context of forms, this means a form should work even with JavaScript disabled, but offer enhanced features (like instant validation) when JavaScript is available.
- End-to-End Type Safety: Ensuring that data types are consistently enforced and validated across all layers of an application, from the user interface (client-side) to the backend (server-side) and database. This minimizes type-related errors, improves code maintainability, and provides strong guarantees about data consistency.
- Zod: A TypeScript-first schema declaration and validation library. It allows developers to define schemas for any data structure, which can then be used to validate incoming data and infer TypeScript types. Zod's powerful inference capabilities are a cornerstone of end-to-end type safety.
zod-form-data: A Zod preprocessor that specifically handlesFormDataobjects. It allows you to define a Zod schema to validate and transform data submitted via HTML forms, treating file uploads, checkboxes, and multi-selects gracefully. Importantly, it can automatically coerce string values fromFormDatainto their correct types (e.g., numbers, booleans) based on the Zod schema.
Achieving Progressive Enhancement and End-to-End Type Safety
The core idea is to define a single, authoritative Zod schema that represents the expected structure and types of our form data. This schema will then be used both on the client to provide immediate feedback and on the server to rigorously validate incoming submissions. zod-form-data bridges the gap by allowing this Zod schema to directly process the raw FormData object from an HTML form submission.
Let's illustrate this with practical examples in both Remix and Next.js.
Example in Remix
Remix's action function is a natural fit for handling form submissions. We can define our Zod schema once and use it within the action.
// 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. Define the Zod schema for our form data const newsletterSchema = zfd.formData({ email: zfd.text(z.string().email("Invalid email address")), 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. Parse and validate the form data using the schema const data = newsletterSchema.parse(formData); // 3. If validation passes, process the data console.log("Newsletter signup data:", data); // In a real app, you would save this to a database, send an email, etc. return json({ success: true, message: "Thanks for signing up!" }); } catch (error) { // 4. If validation fails, return errors if (error instanceof z.ZodError) { const errors = error.flatten(); return json({ success: false, errors: errors.fieldErrors }, { status: 400 }); } return json({ success: false, message: "An unexpected error occurred." }, { 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">Subscribe to our Newsletter</h1> <Form method="post" className="space-y-4"> <div> <label htmlFor="email" className="block text-sm font-medium text-gray-700">Email:</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">How did you hear about us? (Optional)</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"> I accept the terms and conditions </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" > Subscribe </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> ); }
Explanation:
- Schema Definition: We define
newsletterSchemausingzfd.formData. Notice howzfd.textis used for text inputs, andzfd.checkboxfor checkboxes.zfd.checkboxcorrectly parses the presence of the checkbox value into a boolean. - Server-Side Validation (Remix
action): Inside theactionfunction, we get theformDatafrom the request.newsletterSchema.parse(formData)attempts to validate and coerce theFormDatainto the defined types. If validation fails, aZodErroris thrown, which we catch to return specific field errors. - Progressive Enhancement: If JavaScript is disabled, the form will submit directly to the
actionendpoint, and the server-side validation will still work, returning appropriate HTTP status codes and error messages. - Client-Side Feedback with Type Safety:
useActionDataprovides the validation results from the server back to the UI. Sinceaction's return type is known,actionDatais fully typed, allowing us to display errors for specific fields with confidence.
Example in Next.js
For Next.js, we typically use API routes or Server Actions (introduced in Next.js 13.4+) to handle form submissions. We'll show an example using API routes, which is more broadly applicable.
// pages/api/newsletter.ts (API Route) import type { NextApiRequest, NextApiResponse } from 'next'; import { z } from 'zod'; import { zfd } from 'zod-form-data'; // 1. Define the Zod schema for our form data (same as Remix) const newsletterSchema = zfd.formData({ email: zfd.text(z.string().email("Invalid email address")), 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 routes don't directly expose FormData on req.body for application/x-www-form-urlencoded // We'll simulate formData for simplicity, but in a real scenario with actual FormData, // you might need a middleware like `next-connect` or `formidable` to parse it correctly, // or ensure your client sends `application/json` if not using FormData for files. // For simple forms, `req.body` directly maps if `Content-Type: application/x-www-form-urlencoded` // or application/json for `fetch` based submissions. // Let's assume for this example that req.body comes from a simple form post to simulate FormData parsing. // If your client is sending `application/json` (e.g., with Axios/fetch body: JSON.stringify(data)), // you'd parse `req.body` directly. // If your client is sending actual `FormData` (e.g., for file uploads), `req.body` won't be // a `FormData` object automatically. You'd use a library like `formidable` to parse the request stream. // For demonstration, let's convert `req.body` into a simulated `FormData` object if needed, // or directly parse `req.body` as if it were a simple object from `application/x-www-form-urlencoded`. // A more robust way to handle FormData in Next.js API routes: // You'd typically use a library like 'formidable' or 'multer' to parse multipart/form-data. // For application/x-www-form-urlencoded, req.body is already parsed. // Let's adapt our schema to parse the already-parsed `req.body` if it's application/x-www-form-urlencoded // or assume a structure similar to what `zfd.formData` expects after manual parsing. // For simplicity, let's assume `req.body` is an object directly representing form fields // that zfd can then process as if it were FormData. This works for application/x-www-form-urlencoded. const formDataLikeObject = new FormData(); for (const key in req.body) { // Handle array-like values such as multiple checkboxes or select options if (Array.isArray(req.body[key])) { req.body[key].forEach((item: string) => formDataLikeObject.append(key, item)); } else { formDataLikeObject.append(key, req.body[key]); } } // 2. Parse and validate the form data using the schema const data = newsletterSchema.parse(formDataLikeObject); // 3. If validation passes, process the data console.log("Newsletter signup data:", data); // Save to database, send email, etc. return res.status(200).json({ success: true, message: "Thanks for signing up!" }); } catch (error) { // 4. If validation fails, return errors 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: "An unexpected error occurred." }); } }
// pages/newsletter-signup.tsx (Client-Side Page) import { useState } from 'react'; import { z } from 'zod'; // Import Zod for client-side validation hint // Use the same schema for client-side validation to ensure consistency const newsletterClientSchema = z.object({ email: z.string().email("Invalid email address"), source: z.string().optional(), // Note: zfd.checkbox implicitly handles 'on'/'off' or missing. // For pure client-side Zod, you'd check for a boolean directly. acceptTerms: z.boolean().refine(val => val === true, "You must accept the terms and conditions"), }); 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()); // Client-side pre-validation for immediate feedback try { newsletterClientSchema.parse({ ...formObject, acceptTerms: formData.get('acceptTerms') === 'on' // Convert checkbox value }); } catch (error) { if (error instanceof z.ZodError) { setStatus({ success: false, errors: error.flatten().fieldErrors }); return; } } try { const response = await fetch('/api/newsletter', { method: 'POST', // Using FormData directly works best for files, but for simple text fields // `application/x-www-form-urlencoded` or `application/json` is also common. // For this example `formData` goes as `multipart/form-data` indirectly. // If you explicitly want `application/x-www-form-urlencoded`, you'd convert formData to URLSearchParams body: formData, }); const data = await response.json(); setStatus(data); } catch (error) { setStatus({ success: false, message: "Network error. Please try again." }); } }; return ( <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md"> <h1 className="text-2xl font-bold mb-4">Subscribe to our Newsletter</h1> <form onSubmit={handleSubmit} className="space-y-4"> <div> <label htmlFor="email" className="block text-sm font-medium text-gray-700">Email:</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">How did you hear about us? (Optional)</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"> I accept the terms and conditions </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" > Subscribe </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> ); }
Explanation:
- Schema Definition: The
newsletterSchemaremains identical. This is crucial for end-to-end type safety. - Server-Side Validation (Next.js API Route):
NextApiRequest'sreq.bodydoes not automatically provide aFormDataobject formultipart/form-datarequests. For simpleapplication/x-www-form-urlencodedsubmissions,req.bodyis parsed into an object.- We manually reconstruct a
FormDataobject fromreq.bodyto makezfd.formDatawork seamlessly. In a production scenario with actualFormData(e.g., file uploads), you would use a middleware likeformidableto parse themultipart/form-datastream and then pass the parsed fields tozfd.formData. - Error handling is similar to Remix, returning JSON responses.
- Client-Side Validation & Progressive Enhancement:
- We use the same
zschema shape for client-side validation for instant feedback, converting checkbox values appropriately. - When the form is submitted,
fetchsends theFormDatato the API route. - If JavaScript is disabled, the browser will gracefully fall back to a traditional form submission, but because it's a client-side route, it won't work in the same "progressive enhancement" way as a Remix action where the form literally posts to the same route. For true progressive enhancement in Next.js without Server Actions, you might need a dedicated page for
POSTrequests or use a full-page reload on error. Next.js Server Actions drastically simplify this, making it behave much like Remix. - Client-side validation provides immediate user feedback, while server-side validation acts as a definitive safeguard.
- We use the same
Conclusion
By integrating zod-form-data with Zod, we establish a robust pattern for handling forms in Remix and Next.js. This approach centralizes form schema definition, ensures end-to-end type safety from the UI to the backend, and inherently supports progressive enhancement. Developers benefit from reduced boilerplate, compile-time error checking, and a consistent validation story across their application, ultimately leading to more reliable and maintainable forms. This powerful combination significantly elevates the developer experience and the quality of user interaction with forms.

