Arnaud Renaud

Both-sides form validation with Next.js (React Hook Form & next-safe-action)

TL;DR

✅ Result:

  • a form that submits to a server-side action
  • shared validation logic on both client and server
  • browser-side form state management: loading, success, and error states

🛠️ Tech stack:

  • Next.js Server Actions
  • next-safe-action
  • Zod
  • React Hook Form

📦 Code: https://github.com/arnaudrenaud/nextjs-forms-both-sides-validation

The problem

Forms have been – and still are – a pain for the web developer.

In particular, one has to enforce server-side validation for security, while also providing client-side validation for user experience (HTML-only validation does not cover all cases).

Without systematic handling, this is a tedious job, often leading the developer to overlook both security and user experience.

Let’s see how the React-Next.js toolkit can make this easier.


🤔 My code is in TypeScript. Why would I need server-side type validation?

⚠️ TypeScript does not validate types at runtime. Besides, a Zod schema allows us to define constraints that go beyond TypeScript types, such as string length.


A developer-friendly solution

We would like to have:

  • arguments validated in real-time browser-side
  • arguments validated server-side before running the Next.js Server Action
  • a React hook to gather it all and access form state: field validation errors and submission state (loading, success, error)

Demo

Let’s implement a form with a single email field.

Form schema

Using Zod, the form schema can be expressed this way:

export const schema = z.object({
  email: z.string().email(),
});

Form submission server-side

"use server";

import { schema } from "@/app/example/schema";
import { makeServerAction } from "@/lib/server/makeServerAction";

export const action = makeServerAction(schema, async ({ email }) => {
  console.log(`Processing form on the server with email "${email}"…`);

  return { message: `Processed ${email} successfully.` };
});

makeServerAction is our wrapper function binding the schema with the form submission handler.

With this wrapper, when the server receives a form submission, if validation fails for any field, an error is returned.

Form client-side

1. Form submission

"use client";

import { useFormServerAction } from "@/lib/browser/useFormServerAction";
import { schema } from "@/app/example/schema";
import { action } from "@/app/example/action";

export function Form() {
  const { form, submit } = useFormServerAction(schema, action);

  return (
    <form onSubmit={submit}>
      <label>
        Email address:
        <input type="email" {...form.register("email")} />
      </label>

      <button>Submit</button>
    </form>
  );
}

useFormServerAction will be our custom hook binding the browser-side form with the action and schema declared earlier.

For now, we only use:

  • form.register to bind the field to the form state
  • submit to call the action when submitting the form

2. Browser-side schema validation

Here, we see both our own validation (message in red) and the HTML-based validation offered by the browser (tooltip).
export function Form() {
  const {
    form,
+   fieldErrors,
    submit
  } = useFormServerAction(schema, action);

  return (
    <form onSubmit={submit}>
      <label>
        Email address:
        <input type="email" {...form.register("email")} />
      </label>
+     {fieldErrors.email && (
+       <div className="text-sm text-red-500">
+         {fieldErrors.email.message}
+       </div>
+     )}

      <button>Submit</button>
    </form>
  );
}

Our hook performs field validation in the browser (by default, on field blur) following the same schema as the one used on the server.

3. Server-side schema validation

Let’s make sure server-side validation is enforced when bypassing the web interface and submitting a malformed email value:

The server does not run the action and it responds with an error object:

{ "validationErrors": { "email": { "_errors": ["Invalid email"] } } }

4. Success state

export function Form() {
  const {
    form,
    fieldErrors,
    submit,
+   action: { result },
  } = useFormServerAction(schema, action);

  return (
    <form onSubmit={submit}>
      <label>
        Email address:
        <input type="email" {...form.register("email")} />
      </label>
      {fieldErrors.email && (
        <div className="text-sm text-red-500">
          {fieldErrors.email.message}
        </div>
      )}

      <button>Submit</button>
+
+     {result.data && (
+       <div className="text-green-500">{result.data.message}</div>
+     )}
    </form>
  );
}

5. Loading state

export function Form() {
  const {
    form,
    fieldErrors,
    submit,
    action: {
+     isExecuting,
      result
    },
  } = useFormServerAction(schema, action);

  return (
    <form onSubmit={submit}>
      <label>
        Email address:
        <input type="email" {...form.register("email")} />
      </label>
      {fieldErrors.email && (
        <div className="text-sm text-red-500">
          {fieldErrors.email.message}
        </div>
      )}

-     <button>Submit</button>
+     <button disabled={isExecuting}>
+       {isExecuting ? "Loading…" : "Submit"}
+     </button>

      {!isExecuting && result.data && (
        <div className="text-green-500">{result.data.message}</div>
      )}
    </form>
  );
}

6. Error state

Unexpected errors are hidden from the user, replaced by a default message to avoid exposing any implementation details:

On the other hand, known exceptions are shown to help the user:

To throw an error recognized as a known exception, you must instantiate it as an Exception (a custom class that inherits the native Error).

export function Form() {
  const {
    form,
    fieldErrors,
    submit,
    action: { isExecuting, result },
  } = useFormServerAction(schema, action);

  return (
    <form onSubmit={submit}>
      <label>
        Email address:
        <input type="email" {...form.register("email")} />
      </label>
      {fieldErrors.email && (
        <div className="text-sm text-red-500">
          {fieldErrors.email.message}
        </div>
      )}

      <button disabled={isExecuting}>
        {isExecuting ? "Loading…" : "Submit"}
      </button>

      {result.data && (
        <div className="text-green-500">{result.data.message}</div>
      )}

+     {result.serverError && (
+       <div className="text-red-500">{result.serverError}</div>
+     )}
    </form>
  );
}

Full code

Take a look at the wrapper functions makeServerAction and useFormServerAction to glue action, schema and form.

📦 Code: https://github.com/arnaudrenaud/nextjs-forms-both-sides-validation

Summary

With a small set of abstractions, we get:

  • Consistent validation across browser and server using a shared Zod schema
  • Simplified form components, thanks to a custom hook that abstracts form state

This setup scales well as forms grow in complexity, and helps you avoid bugs from mismatched validation logic.

Next steps

You can copy the wrapper functions to your Next.js codebase to start using this pattern today.

To tweak or extend behavior, check out the underlying tools:

comments powered by Disqus