Next.js form validation on the client and server with Zod

Building robust forms in Next.js is thirsty work!

Not only must you validate forms on the server, you must validate them on the client as well.

On the client, to ensure the user has a smooth experience, fields should be revalidated when their value…


This content originally appeared on DEV Community and was authored by Alex Booker

Building robust forms in Next.js is thirsty work!

Not only must you validate forms on the server, you must validate them on the client as well.

On the client, to ensure the user has a smooth experience, fields should be revalidated when their value changes, but only if the field has been "touched" or the form previously-submitted.

If JavaScript is disabled, the form ought to regress gracefully. Dynamic form validation won't be possible, but errors should still render alongside their respective fields and preserve their values between requests to the server.

You want to do all this without writing a bunch of duplicate code and, in this case, without a form library like React Hook Form.

Here's how a senior developer would do it utilising Zod ⬇️

Zod allows you to define the shape of a valid for submission. Provided you do so in a separate file, you can reference the definition from either the server or a client component, eliminating the possibility of duplicate code.

import { z } from "zod"

export const signUpFormSchema = z.object({
  email: z.string().email({ message: "Please enter a valid email." }).trim(),
  password: z
    .string()
    .min(8, { message: "Be at least 8 characters long" })
    .regex(/[a-zA-Z]/, { message: "Contain at least one letter." })
    .regex(/[0-9]/, { message: "Contain at least one number." })
    .regex(/[^a-zA-Z0-9]/, {
      message: "Contain at least one special character."
    })
    .trim()
})

export type SignUpActionState = {
  form?: {
    email?: string
    password?: string
  }
  errors?: {
    email?: string[]
    password?: string[]
  }
}

To validate the form on the server, import and and validate against the schema when the sever action is submitted:

"use server"

import { redirect } from "next/navigation"
import { SignUpActionState, signUpFormSchema } from "./schema"

export async function signUpAction(
  _prev: SignUpActionState,
  formData: FormData
): Promise<SignUpActionState> {
  const form = Object.fromEntries(formData)
  const validationResult = signUpFormSchema.safeParse(form)
  if (!validationResult.success) {
    return {
      form,
      errors: validationResult.error.flatten().fieldErrors
    }
  }

  redirect("/")
}

On the client, in a client component denoted with "use client", create your form:

ℹ️ Info
<ValidatedInput /> isn't defined yet - take a moment to understand the form first
"use client"

import { useActionState, useState } from "react"
import { signUpAction } from "./action"
import { signUpFormSchema } from "./schema"
import { ValidatedInput } from "@/components/ui/validated-input"

export default function SignUpForm() {
  const [wasSubmitted, setWasSubmitted] = useState(false)

  const [state, action, isPending] = useActionState(signUpAction, {})

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    setWasSubmitted(true)
    const formData = new FormData(event.currentTarget)
    const data = Object.fromEntries(formData)
    const validationResult = signUpFormSchema.safeParse(data)
    if (!validationResult.success) {
      event.preventDefault()
    }
  }

  return (
    <form onSubmit={handleSubmit} action={action} noValidate>
      <div>
        <label htmlFor="email">Email:</label>
        <ValidatedInput
          type="email"
          name="email"
          wasSubmitted={wasSubmitted}
          fieldSchema={signUpFormSchema.shape["email"]}
          defaultValue={state.form?.email}
          errors={state.errors?.email}
        />
      </div>
      <div>
        <label htmlFor="password">Password:</label>
        <ValidatedInput
          type="password"
          name="password"
          fieldSchema={signUpFormSchema.shape["password"]}
          wasSubmitted={wasSubmitted}
          defaultValue={state.form?.password}
          errors={state.errors?.password}
        />
      </div>
      <div>
        <button type="submit" disabled={isPending}>
          Continue
        </button>
      </div>
    </form>
  )
}

When the form is submitted, onSubmit validates the form before indirectly invoking the server action.

The form component above is not concerned about rendering errors - that is the responsibility of <ValidatedInput />:

<ValidatedInput
  type="password"
  name="password"
  fieldSchema={signUpFormSchema.shape["password"]}
  wasSubmitted={wasSubmitted}
  defaultValue={state.form?.password}
  errors={state.errors?.password}
/>

Note how we extract the fieldSchema from signUpFormSchema using signUpFormSchema.shape. By passing the field schema in this way, <ValidatedInput /> remains flexible and reusable across your different forms.

Here's <ValidatedInput /> in full:

import { useState, useCallback } from "react"
import { Input } from "./input"

const ValidatedInput = ({
  name,
  wasSubmitted,
  errors,
  fieldSchema,
  ...props
}) => {
  const [value, setValue] = useState("")
  const [touched, setTouched] = useState(false)

  const getErrors = useCallback(() => {
    const validationResult = fieldSchema.safeParse(value)
    return validationResult.success
      ? []
      : validationResult.error.flatten().formErrors
  }, [fieldSchema, value])

  const fieldErrors = errors || getErrors()
  const shouldRenderErrors = errors || wasSubmitted || touched

  const handleBlur = () => setTouched(true)
  const handleChange = (e) => setValue(e.currentTarget.value)

  return (
    <>
      <Input
        id={name}
        name={name}
        onBlur={handleBlur}
        onChange={handleChange}
        className={fieldErrors.length > 0 ? "border-red-500" : ""}
        {...props}
      />
      {shouldRenderErrors && (
        <span className="text-sm text-red-500">{fieldErrors}</span>
      )}
    </>
  )
}
export { ValidatedInput }

It's based on Kent's FastInput.


This content originally appeared on DEV Community and was authored by Alex Booker


Print Share Comment Cite Upload Translate Updates
APA

Alex Booker | Sciencx (2025-01-01T14:33:47+00:00) Next.js form validation on the client and server with Zod. Retrieved from https://www.scien.cx/2025/01/01/next-js-form-validation-on-the-client-and-server-with-zod/

MLA
" » Next.js form validation on the client and server with Zod." Alex Booker | Sciencx - Wednesday January 1, 2025, https://www.scien.cx/2025/01/01/next-js-form-validation-on-the-client-and-server-with-zod/
HARVARD
Alex Booker | Sciencx Wednesday January 1, 2025 » Next.js form validation on the client and server with Zod., viewed ,<https://www.scien.cx/2025/01/01/next-js-form-validation-on-the-client-and-server-with-zod/>
VANCOUVER
Alex Booker | Sciencx - » Next.js form validation on the client and server with Zod. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/01/01/next-js-form-validation-on-the-client-and-server-with-zod/
CHICAGO
" » Next.js form validation on the client and server with Zod." Alex Booker | Sciencx - Accessed . https://www.scien.cx/2025/01/01/next-js-form-validation-on-the-client-and-server-with-zod/
IEEE
" » Next.js form validation on the client and server with Zod." Alex Booker | Sciencx [Online]. Available: https://www.scien.cx/2025/01/01/next-js-form-validation-on-the-client-and-server-with-zod/. [Accessed: ]
rf:citation
» Next.js form validation on the client and server with Zod | Alex Booker | Sciencx | https://www.scien.cx/2025/01/01/next-js-form-validation-on-the-client-and-server-with-zod/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.