Remix Form Validation — Server-Side and Client-Side
In this tutorial, you will learn about Remix Form Validation. We cover key concepts, practical examples, and best practices to help you master this topic.
Learn Remix form validation: implement server-side validation with Zod, show field-level errors, add client-side validation, and create reusable validators.
In this lesson, you'll validate form data on the server using Zod schemas, return field-level error messages, and add client-side validation for immediate feedback.
What You'll Learn
How to define validation schemas with Zod, validate in actions, return structured errors, add client-side validation, and create reusable validation utilities.
Why It Matters
Server-side validation is mandatory for security. Client-side validation improves user experience. Both together ensure data integrity and a smooth user experience.
Real-World Use
DodaZIP uses Zod schemas for all form validation, ensuring consistent validation rules on both server and client.
flowchart LR
A[Form Submit] --> B[Client Validation]
B -->|Pass| C[Server Validation]
B -->|Fail| D[Show Errors]
C -->|Pass| E[Process Data]
C -->|Fail| F[Return Errors]
style C fill:#121212,color:#fff
Zod Validation Schema
import { z } from "zod";
const userSchema = z.object({
email: z.string().email("Valid email is required"),
name: z.string().min(2, "Name must be at least 2 characters"),
age: z.coerce.number().min(18, "Must be 18 or older"),
});
Using Validation in Actions
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
name: z.string().min(2),
age: z.coerce.number().min(18),
});
export const action = async ({ request }) => {
const formData = await request.formData();
const data = Object.fromEntries(formData);
const result = schema.safeParse(data);
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
return json({ errors }, { status: 422 });
}
await createUser(result.data);
return redirect("/users");
};
export default function NewUser() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="email" />
{actionData?.errors?.email && <p>{actionData.errors.email}</p>}
<input name="name" />
{actionData?.errors?.name && <p>{actionData.errors.name}</p>}
<input name="age" type="number" />
{actionData?.errors?.age && <p>{actionData.errors.age}</p>}
<button type="submit">Create</button>
</Form>
);
}
Client-Side Validation
Add HTML5 validation attributes and custom JavaScript:
import { useState } from "react";
export default function FormWithClientValidation() {
const [errors, setErrors] = useState({});
const validate = (formData) => {
const data = Object.fromEntries(formData);
const result = schema.safeParse(data);
if (!result.success) {
setErrors(result.error.flatten().fieldErrors);
return false;
}
setErrors({});
return true;
};
return (
<Form
method="post"
onSubmit={(e) => {
if (!validate(new FormData(e.target))) {
e.preventDefault();
}
}}
>
{/* fields */}
</Form>
);
}
Reusable Validator Hook
// app/hooks/useValidatedForm.ts
import { useActionData } from "@remix-run/react";
import { useCallback, useState } from "react";
export function useValidatedForm(schema) {
const actionData = useActionData();
const [clientErrors, setClientErrors] = useState({});
const validate = useCallback((formData) => {
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
setClientErrors(result.error.flatten().fieldErrors);
return false;
}
setClientErrors({});
return true;
}, [schema]);
const errors = clientErrors?.email || actionData?.errors?.email;
return { errors: errors || {}, validate };
}
Common Mistakes
- Only validating on the client: Client validation is easily bypassed. Always validate on the server.
- Not using
safeParse:parse()throws on invalid data.safeParse()returns a result object that's easier to handle. - Returning generic error messages: Show specific field-level errors so users know exactly what to fix.
- Not validating types:
z.coerce.number()converts string inputs to numbers. Without it, form values (strings) fail type validation.
Practice Questions
Why is server-side validation mandatory? Answer: Client-side validation can be bypassed. Server validation is the only reliable protection against invalid data.
What is the difference between
parse()andsafeParse()? Answer:parse()throws on invalid data.safeParse()returns an object withsuccessboolean and eitherdataorerror.How do you access field-level errors from Zod? Answer: Use
result.error.flatten().fieldErrorswhich returns an object keyed by field name with arrays of error messages.What does
z.coerce.number()do? Answer: It coerces string input (from form data) to a number before validation. Useful since form values are always strings.
Challenge
Create a registration form with Zod validation for email, password (min 8 chars, must contain number), confirm password (must match), and terms acceptance. Show field-level errors on both client and server.
Mini Project
Build a product creation form with validation for name (required), price (positive number), description (max 500 chars), category (from enum), and image URL (valid URL). Use a reusable validation hook.
FAQ
What's Next
Learn about Remix Error Boundaries for handling errors gracefully in routes.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro