Techtales.

Handling Forms in Next.js with Server Actions
TD
The Don✨Author
Aug 12, 2025
7 min read

Handling Forms in Next.js with Server Actions

02

As a web developer, you will constantly need to deal with forms. It is also good to note that forms also introduce a major security risk to your app, as they provide attackers with a direct point of entry to send malicious data and code to a website's server, expanding potential vulnerabilities. Handling forms appropriately is therefore a crucial skill for each web developer.

In React, we often use state to track form inputs and conduct client-side validation. Using state allows us to track each input individually and also to store it FormData for submission. However, current React version React 19) and NextJS server actions have eliminated the need to store form entries in state, as these will cause a re-render each time a form field changes.

In this blog, I will share an efficient way to handle forms in Next.js using server actions for form submissions and Zod for server-side validations. Furthermore, we will look into how to use the React useActionState hook to show a loading spinner when the form is being submitted.

Prerequisites

To begin, create a new NextJS app and install Zod. We will be building a contact form that allows users to enter their email, name, and message.


Now let us create our form schema using Zod. I usually like to create a dedicated file or folder for all form schemas, although this is just one simple form, so we can just create the schema anywhere we like. We can also create a function to validate our email inputs to ensure users type correct emails. In our case, we can add this in lib/schemas.ts the file.

Building the Form

To begin, we can create a bare-back form component that contains all our three inputs for the name, email, and message. We will later modify the form to add functionality using server actions.

If everything is set up correctly, your form should appear like below. We also made the form a client component since we have events that will trigger when the form is submitted.

Creating the Server Action

Server actions are pretty much what they sound like—actions or functions that run on the server. With server actions, you can make calls to external APIs or fetch data from a database. We can create server actions by creating an actions file and adding "use server" a directive at the top.

In this case, we need a server action that can validate our FormData and submit the email containing the user contact information. We need this server action to validate the data using our Zod schema, and if there is an error, return back the errors to the form together with the previous data.

Here is what is going on with our server function. This server function takes the contact form submission, checks if the name, email, and message are valid using a schema, and if anything’s wrong, it sends back detailed error messages along with the user’s original input so they don’t have to retype it.

If everything is valid, it tries to send the email (currently just a simulation) and then responds with either a success message or an error message if something went wrong during sending. It all happens securely on the server, so no sensitive logic or validation rules are exposed to the browser.

Adding Functionality to Our Form

Next, we need to rebuild our form to add the functionality. We will be using the React useActionState hook for this. useActionState is a hook that allows you to update state based on the result of a form action.


The hook looks something like this:

Per React documentation, we should call useActionState at the top level of our component to create component state that is updated when a form action is invoked. You pass useActionState an existing form action function as well as an initial state, and it returns a new action that you use in your form, along with the latest form state and whether the action is still pending. The latest form state is also passed to the function that you provided.

useActionState returns an array with the following items:

  1. The current state of the form, which is initially set to the initial state you provided, and after the form is submitted, is set to the return value of the action you provided.
  2. new action that you pass to <form> as its action prop or call manually within startTransition.
  3. pending state that you can utilize while your action is processing.

When the form is submitted, the action function that you provided will be called. Its return value will become the new current state of the form.

The action that you provide will also receive a new first argument, namely the current state of the form. The first time the form is submitted, this will be the initial state you provided, while with subsequent submissions, it will be the return value from the last time the action was called. The rest of the arguments are the same as if they useActionState had not been used.

We have updated the form to use React’s useActionState with the sendEmail server action, allowing it to handle submissions interactively while still processing validation securely on the server. This change means the form now receives an objectstate object containing any validation errors, success flags, messages, and the user’s submitted data, which we use to highlight invalid fields, display error messages directly under them, and repopulate inputs after failed submissions so users don’t have to retype.

The isPending flag lets us disable the submit button and show a “Sending...” label during processing, and we show a green success message if the email sends successfully or a red error message if something goes wrong, creating a smoother, more user-friendly submission experience.

Conclusion

In this blog, we have covered some strategies for validating and submitting forms without using state to track the values of the input fields. This strategy enhances security by ensuring validation happens on the server before the data is accepted. It also keeps the code DRY by avoiding duplication in the client and server. Let me know of other examples where this use case might be necessary. 

0
2