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>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
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:
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 (
Email address:
{fieldErrors.email && (
{fieldErrors.email.message}
)}
Submit
+
+ {result.data && (
+ {result.data.message}
+ )}
);
}
5. Loading state
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:
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 (
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:
- next-safe-action's
createSafeActionClient
- React Hook Form's
useForm
- Zod schema primitives (you can also define multi-field validation rules)