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

Video showing form submission processed on the server.

"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>Submitbutton>
    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

Video showing browser-side schema validation on field blur.

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 (
    
      
        Email address:
        
      
+     {fieldErrors.email && (
+       
+         {fieldErrors.email.message}
+       
+     )}

      Submit
    
  );
}

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:

Video showing server response to a malformed submission.

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

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

4. Success state

Video showing success message to the user after server response.

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

  return (
    
      
        Email address:
        
      
      {fieldErrors.email && (
        
          {fieldErrors.email.message}
        
      )}

      Submit
+
+     {result.data && (
+       {result.data.message}
+     )}
    
  );
}

5. Loading state

Video showing disabled button while waiting for server response.

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

  return (
    
      
        Email address:
        
      
      {fieldErrors.email && (
        
          {fieldErrors.email.message}
        
      )}

-     Submit
+     
+       {isExecuting ? "Loading…" : "Submit"}
+     

      {!isExecuting && result.data && (
        {result.data.message}
      )}
    
  );
}

6. Error state

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

Video showing default error message to the user after server response.

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

Video showing specific exception message to the user after server response.

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 (
    
      
        Email address:
        
      
      {fieldErrors.email && (
        
          {fieldErrors.email.message}
        
      )}

      
        {isExecuting ? "Loading…" : "Submit"}
      

      {result.data && (
        {result.data.message}
      )}

+     {result.serverError && (
+       {result.serverError}
+     )}
    
  );
}

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: