Handle form errors correctly in a Next.js app built with the app router

I've embraced the idea of React Server Component (RSC), and I think it's a great addition to React and the way to move forward. The whole point of RSC, to me, is the ability to create superior user experiences. With RSC, you ship less JavaScript to the client and make your application snappier by moving expensive operations to the server.

However, in some cases, RSC can make the user experience even worse, and you should be mindful of it. Using a form as a server component is one such case. Let's take a look at how this is so and how we can solve it.

Background

For its benefits, I want to use as many server components as I can in my application (you should too). So, I tried to use forms as server components, and everything worked fine until it didn't. The main problem I faced was the error handling in a form. A form being a server component, I didn't find a viable way to notify a user of a meaningful error message. Let me explain.

Let's say your server (action/endpoint) throws an error when a user doesn't provide a required field. You can catch the error in the error.tsx file. However, it will show the actual error message you intended to show during development only. In production, Next.js replaces the message with a generic one that doesn't provide any context about the error.

This generic message makes a user confused by not indicating what he/she has done wrong. That's a terrible user experience, in my opinion, and you can easily lose the user for this confusing behavior. This is a case where by using RSC we've created a worse user experience. What's the solution then?

Solution

My solution is to make each form a client component. By doing so, we can use hooks in it. Using the hooks or a toast library, we can show meaningful error messages to the users at any point in the submission process. The following is an example:

async function handleSubmit(formData: FormData) {
  const name = formData.get('name');
  const email = formData.get('email');
  const message = formData.get('message');

  if (!name || !email || !message)
    return setAlert({
      message: 'Name, email, or message is missing',
      type: 'failed',
    });
  if (!isValidEmail(email as string))
    return setAlert({
      message: 'Please provide a valid email',
      type: 'failed',
    });
  const { error } = await submitContactForm(name, email, message);
  if (error) return setAlert({ message: error.message, type: 'failed' });

  router.push('/contact/confirmation');
}

As you can see in the above code, when a user tries to submit the form, we check if he/she has provided the required fields. If not, we use the setAlert function to notify the user that he/she has missed something. We can do the same in the case of an invalid email address as well.

We perform these checks even before the server action is called. And when the client-side validation is passed, we call the server action with the form data. From the server action, if we get an error, we notify the user about it. Otherwise, we redirect them to the confirmation page.

This improves the user experience by providing meaningful error messages to the users as you intend to. You can ask though, don't we lose the other benefits of RSC by making the form a client component? Of course, we do, but what we lose is very negligible compared to what we gain here. And that's a sweet tradeoff I would make any day until we get better error handling in RSC.

Share this article with your friends

Copy URL

Elevate your JavaScript and freelance journey

Supercharge your JavaScript skills and freelance career. Subscribe now for expert tips and insights!