Skip to content

Remix Form Validation — Server-Side and Client-Side

DodaTech Updated 2026-06-28 4 min read

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

  1. Only validating on the client: Client validation is easily bypassed. Always validate on the server.
  2. Not using safeParse: parse() throws on invalid data. safeParse() returns a result object that's easier to handle.
  3. Returning generic error messages: Show specific field-level errors so users know exactly what to fix.
  4. Not validating types: z.coerce.number() converts string inputs to numbers. Without it, form values (strings) fail type validation.

Practice Questions

  1. Why is server-side validation mandatory? Answer: Client-side validation can be bypassed. Server validation is the only reliable protection against invalid data.

  2. What is the difference between parse() and safeParse()? Answer: parse() throws on invalid data. safeParse() returns an object with success boolean and either data or error.

  3. How do you access field-level errors from Zod? Answer: Use result.error.flatten().fieldErrors which returns an object keyed by field name with arrays of error messages.

  4. 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

Can I share validation schemas between client and server?

: Yes. Define schemas in a shared file imported by both the route module and client components.

How do I validate arrays in forms?

: Zod supports z.array() with nested schemas. Form arrays use duplicate field names with [] suffix.

What about cross-field validation?

: Use z.refine() with a function that accesses multiple fields, like password/confirm-password matching.

Can I use Yup instead of Zod?

: Yes. Any validation library works. Zod is recommended for TypeScript integration.

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