When implementing forms with server actions in NextJS, data validation is a critical step before processing the submission. Rather than duplicating validation logic across multiple forms, we can create a reusable wrapper that handles both server action creation and data validation seamlessly.
This implementation leverages the following technologies:
- Zod - TypeScript-first schema validation
- NextJS - Server actions and React framework
- Shadcn UI - Accessible UI components
- React Hook Form - Form state management
This guide demonstrates how to integrate these technologies to create a robust form validation system.
The schema we will use in the example
import { z } from 'zod';
export const signInSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export type SignInSchema = z.infer<typeof signInSchema>;
create-server-action
Let's start by creating a wrapper that will handle the server action creation and data validation.
import { type z, type ZodSchema } from 'zod';
// This is the type that will be returned by the server action and re-used throughout the app
// You could always update this to take a generic type and return a different type
// But I prefer consistency and having a single type for the server action state
export type ServerActionState = {
success: boolean;
message?: string;
errors?: Record<string, string[]>;
fields?: Record<string, string | File>;
};
/**
* Creates a server action that validates and executes a Zod schema.
*
* @param schema - The Zod schema to validate the form data against.
* @param action - The action to execute with the validated data.
* @returns A server action function that can be used in a form submission.
*/
export const createServerAction = <T extends ZodSchema>(
schema: T,
action: (params: {
data: z.infer<T>;
prevState: ServerActionState;
}) => Promise<ServerActionState>,
) => {
return async (
prevState: ServerActionState,
formData: FormData,
): Promise<ServerActionState> => {
const formDataEntries = Object.fromEntries(formData.entries());
// Use safeParse instead of parse so we don't need a try/catch block
// since that will break nextjs server redirects
const validationResult = schema.safeParse(formDataEntries);
if (!validationResult.success) {
return {
success: false,
fields: Object.fromEntries(
Array.from(formData.entries()).map(([key, value]) => [
key,
typeof value === 'string' ? value : value.name,
]),
),
message: 'The schema validation failed',
};
}
// Execute the action with validated data
return await action({
data: validationResult.data,
prevState,
});
};
};
Using the createServerAction
function, you can create a server action that will validate the form data against a Zod schema.
'use server';
import { createServerAction } from '~/lib/create-server-action';
import { signInSchema } from './sign-in-schema';
export const signInAction = createServerAction(
signInSchema,
async ({ data, prevState }) => {
// The data is the type-safe validated data from the form
// The prevState is the state of the server action before the action was executed
// Return the success state and a message
return { success: true };
},
);
The form hook
To bridge the gap between client-side validation and server-side processing, we've created a custom hook that manages form state and handles validation errors. This hook integrates React Hook Form for client-side validation while leveraging our server action wrapper for backend processing.
import { zodResolver } from '@hookform/resolvers/zod';
import { startTransition, useActionState, useRef } from 'react';
import { useForm, type UseFormProps } from 'react-hook-form';
import { type z } from 'zod';
import { type ServerActionState } from '~/lib/create-server-action/create-server-action';
type UseServerFormProps<T extends z.ZodType> = {
schema: T;
action: (
prevState: ServerActionState,
formData: FormData,
) => Promise<ServerActionState>;
defaultValues?: UseFormProps<z.infer<T>>['defaultValues'];
};
export const useServerForm = <T extends z.ZodType>({
schema,
action,
defaultValues,
}: UseServerFormProps<T>) => {
const [state, formAction, isPending] = useActionState(action, {
success: false,
});
const formRef = useRef<HTMLFormElement>(null);
const form = useForm<z.infer<T>>({
resolver: zodResolver(schema),
defaultValues: state.fields
? (Object.fromEntries(
Object.entries(state.fields).map(([key, value]) => [
key,
typeof value === 'string' ? value : '',
]),
) as typeof defaultValues)
: defaultValues,
});
const handleSubmit = (evt: React.FormEvent<HTMLFormElement>) => {
form.handleSubmit(() => {
startTransition(() => {
formAction(new FormData(formRef.current!));
});
})(evt);
};
return {
form,
formRef,
formAction,
handleSubmit,
isPending,
state,
};
};
Let's use this useServerForm
hook in a form component.
'use client';
import { Button } from '~/components/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '~/components/ui/form';
import { Input } from '~/components/ui/input';
import { InputPassword } from '~/components/ui/input-password';
import { useServerForm } from '~/lib/use-server-form';
import { signInAction } from './sign-in-action';
import { signInSchema } from './sign-in-schema';
export const SignInForm = () => {
const { form, formRef, handleSubmit, isPending, state } = useServerForm({
schema: signInSchema,
action: signInAction,
defaultValues: {
email: '',
password: '',
},
});
return (
<Form {...form}>
<form
ref={formRef}
onSubmit={handleSubmit}
className="flex flex-col gap-4"
>
{state.success === false && state.message && (
// Show any custom error component
)}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<InputPassword
placeholder="Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button loading={isPending} type="submit">
Submit
</Button>
</form>
</Form>
);
};
That's it! You can now use this useServerForm
hook in any form component you want.
Conclusion
This implementation provides a robust and reusable solution for form validation and state management with server actions in NextJS. By leveraging Zod for type-safe validation and React Hook Form for client-side handling, we can create a seamless and maintainable form validation system.
This approach not only reduces boilerplate code but also ensures a consistent and type-safe validation experience across all forms in your application.
Side-note
I'm not saying this is the best way to do this, but it's a way that works for me and I'm happy to share it with you.
If you have any questions or suggestions, please feel free to reach out to me on LinkedIn.