Back to blog overview

Validate server action with Zod

Today

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:

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.