DEV Community

Alex Booker
Alex Booker

Posted on

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 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[]
  }
}

Enter fullscreen mode Exit fullscreen mode

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("/")
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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}
/>
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

It's based on Kent's FastInput.

Top comments (0)