Client Side Form Validation with Zod & useFormState() in Next.js 14

calendar-emoji

26 Feb. 2024

JavascriptNext JS

Authentication in Web Apps is a crucial aspect for securing any Web Application and is a must-know for every Full-Stack developer out there. Though implementing Authentication for the first time can be an annoying experience for people(including me!) as there are so many libraries and strategies out there. So this is part one of a two-part blog I’m writing about implementing Complete Authentication in Next.js 14 using Next-Auth v5.

In this blog I’m going to talk about the easiest way in which you can handle form data in react and perform Client-side Validation in minutes using Zod.

Creating Next app

We’ll start by creating a Next-app from the terminal using the below command:

npx create-next-app@latest

Note: Make sure you have Node.js 18.17(or higher) installed before running this.

Similarly if you have Bun pre-installed instead of Node.js run this command:

bunx create-next-app

Next.js Project Structure

After creating our next app this is what our folder structure looks like for the project, this may seem overwhelming if you’re new to Next.js, but I’ll walk you through how simple this really is:

  • node-modules: if you’ve worked with Node.js before then this folder is self-explanatory, but if this is your first rodeo then this is where all the dependencies for our project are stored. Any external dependency we install using npm is imported from here.
  • public: this folder is where we store all our static assets including images, videos, gifs or any other kind of documents such as pdf’s etc. which are not subject to change, since these files are cached to offer better performance.
  • src: this folder is the heart of our application as this contains our frontend & backend code in the form of .jsx or .tsx files. The app folder inside this contains all our routes for the application in the form of folders while the components folder contains all the custom reusable UI components such as buttons, cards etc.

Each folder in the app directory denotes a route which can be accessed by going to /folder_name. The content of this page will be rendered from the file named as page.js , the file named layout.js defines a set of styles and UI components which will be rendered in every sub-route.

To know more about other files allowed by the App Router take a look at this from the official docs: https://nextjs.org/docs/getting-started/project-structure#routing-files

  • .env: this file contains all the secret environment variables such as secret ID’s & keys that are required to confirm your identity. Split this into .env.development.local and .env.production and never share this anywhere!
  • package.json & package-lock.json: these files keep track of all the packages installed along their versions along with pre-defined scripts to run our project.
  • tailwind.config.js: this file contains all the configurations which tailwind will look for to define any customizations.

Now that we have this out of our way let’s fire up our server in development mode using npm run dev

PS: this command is a script alias defined in the package.json to run the next server in development mode.

Adding Form page

As discussed above, to create a new route we’ll create a new folder in the app directory named as login containing a file named page.js . Any code that we write here is rendered on /login .

We’ll look to create a simple form with Username & Password to login as Credentials or the user can use Google, Github, Reddit & Twitter as OAuth providers.

To quickly build our form page I’m using Tailwind components from Flowbite linked here: https://flowbite.com/docs/components/forms/

But you can use any component library you want, it really doesn’t matter.

Finally, our page looks like this and now we can start working on it.

Client side Form Validation

This is the first step of validating our form data, this step ensures we perform primitive data validation before sending our data to the server. The easiest way to do this would be by using a third-party library like Zod.

Nope, not my Kryptonian friend over here 😅

Zod is a TypeScript-first schema declaration and validation library and is super-easy to use.

Use npm install zod to quickly include Zod in your project setup

Zod provides a number of primitive values in its documentation for fields such as strings, numbers, boolean, date etc. We only have two fields both of which use string values so we can create separate schemas for both of them.

Zod also provides a lot of options for string validation out of the box as shown below which we can use along with the option of chaining all these arguments to be processed sequentially.

z.string().min(5, { message: "Must be 5 or more characters long" });
z.string().max(5, { message: "Must be 5 or fewer characters long" });
z.string().length(5, { message: "Must be exactly 5 characters long" });
z.string().email({ message: "Invalid email address" });
z.string().url({ message: "Invalid url" });
z.string().emoji({ message: "Contains non-emoji characters" });
z.string().uuid({ message: "Invalid UUID" });
z.string().includes("tuna", { message: "Must include tuna" });
z.string().startsWith("https://", { message: "Must provide secure URL" });
z.string().endsWith(".com", { message: "Only .com domains allowed" });
z.string().datetime({ message: "Invalid datetime string! Must be UTC." });
z.string().ip({ message: "Invalid IP address" });

A schema is an object which basically contains a set of rules defined with a data type that can be used to validate any kind of data passed to it.

const userSchema = z.string()
                    .min(5, { message: "Must be 5 or more characters long" });

const passSchema = z.string()
                    .min(8, { message: "Must be 8 or more characters long" })
                    .regex(new RegExp(".*[A-Z].*"), { message: "Must conatain one uppercase character" })
                    .regex(new RegExp(".*\\d.*"), { message: "Must contains one number" })
                    .regex(new RegExp(".*[`~<>?,./!@#$%^&*()\\-_+=\"'|{}\\[\\];:\\\\].*"), {message: "Must contain one special character"}); 

Example usage of this schema using .safeParse() method:

userSchema.safeParse("user");         // ❌ { success: false; error: "Must be 5 or more characters long" }
userSchema.safeParse("Random");       // ✅ { success: true; data: Return "Random' }

passSchema.safeParse("pass");         // ❌ { success: false; error: "Must be 8 or more characters long" }
passSchema.safeParse("password");     // ❌ { success: false; error: "Must conatain one uppercase character" }
passSchema.safeParse("Password");     // ❌ { success: false; error: "Must contains one number" }
passSchema.safeParse("Password123");  // ❌ { success: false; error: "Must contain one special character" }
passSchema.safeParse("Password123*"); // ✅ { success: true; data: "Password123*" }

Now once we have defined this we just need to invoke this at every keyStroke for live validation, to do this we can use the onChange event to call a function that uses .safeParse() to validate the input data.

<input type="text" name="username" onChange={checkSchema} />

Getting Form Data

Though there are number of ways of getting form data in React we’ll again talk about the easiest way to get formData in React. Months back React released a new hook called useFormState() which made interacting with forms a walk in the park.

const [state, formAction] = useFormState(fn, initialState)

From the official docs:

The form state is the value returned by the action when the form was last submitted. If the form has not yet been submitted, it is the initial state that you pass.



Parameters



fn : The function to be called when the form is submitted or button pressed. When the function is called, it will receive the previous state of the form (initially the initialState that you pass, subsequently its previous return value) as its initial argument, followed by the arguments that a form action normally receives.



initialState : The value you want the state to be initially. It can be any serializable value. This argument is ignored after the action is first invoked.



Returns



useFormState returns an array with exactly two values:


The current state. During the first render, it will match the initialState you have passed. After the action is invoked, it will match the value returned by the action.



A new action that you can pass as the action prop to your form component or formAction prop to any button component within the form.

To use this hook we just need to define the useFormState() hook with a function called handleCredentials that will be invoked whenever the form is submitted. Then we can simply use formData.get("input") to get data from that input field.

const handleCredentials = async (prevState, formData) => {

  const response = await signIn("credentials", { 
     username: formData.get("username"),
     password: formData.get("password"),
     redirect: false, 
  });
  
  if(!!response.error) {
    setModal(true);
    setErrorMessage("Incorrect Username or Password");
    console.log(response.error);
  }
  else {
    router.push('/dashboard');
  }
}

const [state, formAction] = useFormState(handleCredentials, initialState);

<form action={formAction}>

Once we have our verified our credentials on the client side we can then send it to the server for Server side validation to make sure the credentials entered are correct or not. Based on that request we’ll get a response, if the response contains an error message we’ll use a dismissible modal to display it.

So with this we have successfully configured Client-side Validation with Zod. For all those bums who skipped through the blog and are just interested in the code, I’ve attached the source code below.

I hope you enjoyed reading this and have a great day!

Source Code:

https://github.com/NikhilC2209/Next-Auth-v5

References:


Next up, we’ll look to send form data to the server side and implement Authentication using Next-Auth v5.