Forms are the backbone of user inputs and interactions in websites. However, as developers, we know the golden rule: never trust user input. Trusting user inputs too much will expose our websites' vulnerabilities. That's why input validation is non-negotiable—it's our job to ensure user interactions don't compromise the security and stability of our websites.

In this article, we'll learn how to create a clean contact form. To do so, we'll build the contact form using ReactJS and CSS Modules. As for the form validation, we'll use Zod.

📦 Source Code: You can find the complete source code for this project in this GitHub Repository.

Prerequisites

Before following this tutorial, make sure you have:

  • Node.js (v22.0 or later) installed on your machine.
  • A code editor installed (e.g., VS Code).
  • Basic knowledge of React (Hooks, State, and Props) and JavaScript (ES6+).
  • Basic knowledge of CSS Modules.
  • Familiarity with running basic commands in your terminal or VS Code integrated terminal.

What We'll Cover

Here’s how we’re going to build it, step by step:

  1. Project Setup and Libraries Installation
  2. Zod Validation Schema
  3. Wiring the Form Component
  4. CSS Modules for Styling
  5. Validation Test
  6. Conclusion

📌 Environment & Versions Used in This Tutorial:

  • Vite: v8.x
  • React: v19.x
  • Zod: v4.6.x
  • react-hook-form: v7.91.x
  • @hookform/resolvers: v6.0.x

Project Setup and Libraries Installation

Open your terminal and create a new project:

npm create vite@latest zod-form-validation -- --template react 

In the interactive Vite CLI, type y and press Enter to proceed, then select ESLint for the linter. When prompted with Install with npm and start now?, I prefer to select No since I like handling the installation manually, but feel free to choose Yes if you want it done automatically.

Since I prefer the manual way, I'll run:

 cd zod-form-validation npm install 

After installing all Vite dependencies, we're now ready to install three libraries: zod, react-hook-form, and @hookform/resolvers for react form validation.

Make sure you're in the correct root project, then run this in your terminal:

 npm install zod react-hook-form @hookform/resolvers 

Once all the dependencies are installed, run your development server with:

 npm run dev 

The terminal should display where your development server is running. By default, vite will run its development server on http://localhost:5173/.

Zod Validation Schema

Before we dive into writing the Zod schema, let's organize our project directory like this:

src ├── App.jsx ├── components │ ├── ContactForm.jsx │ └── ContactForm.module.css ├── main.css ├── main.jsx └── schemas └── contactSchema.js 

Add a new folder in src, name it schemas. Then, create a new file called contactSchema.js.

Here is contactSchema.js:

/* src/schemas/contactSchema.js */ import { z } from 'zod'; export const contactSchema = z.object({ name: z .string() .min(2, { message: 'Name must be at least 2 characters long' }) .max(50, { message: 'Name must not exceed 50 characters' }), email: z.string().email({ message: 'Please enter a valid email address' }), phone: z.e164({ message: 'Phone number must be in E.164 format (e.g., +628123456789)', }), message: z .string() .min(10, { message: 'Message must be at least 10 characters long' }) .max(1000, { message: 'Message must not exceed 1000 characters' }), }); 

contactSchema.js defines our form data as an object and its validation rules. Although Zod can validate each value on its own, in our case, we treat our form as a whole object. This means all properties—name, email, phone, and message—must pass Zod validation. Otherwise, Zod will throw error messages on the frontend.

When defining a Zod object, we import z as a named export from zod. Then, we apply validation rules to each form field based on our needs.

Note how we use the string and email methods to check the data type our users will input into the form. We also chain the string and email methods with the min and max methods for the validation.

Take our message field as an example. We want to ensure it strictly accepts text input. To achieve this, we define the message property using z.string() and chain it with min and max methods.

The validation process for our message field starts by checking if the user entered a valid string into the field.

The min and max methods limit the minimum (10) and maximum (1000) characters allowed in the message field. If either the minimum or maximum validation is violated, Zod will throw the error messages defined inside the min and max methods.

For other methods available in Zod, you can see Zod official documentation.

Note:

In this tutorial, Zod only works on the client side (Frontend) level. If you send the form data to your server, make sure your server has its own validation rules. Do note that any data sent from the client side cannot be trusted.

If you want, the same Zod schema can also be used on the server side. However, it will need further configuration.

Wiring the Form Component

Inside src directory, create a new folder called components. After that, create a new file, ContactForm.jsx, in the directory. Also, create a new file called ContactForm.module.css.

ContactForm.module.css is the CSS module for our form component. You'll see how it looks in the next section. In the current section, I'll show you how ContactForm.jsx looks like:

/* src/components/ContactForm.jsx */ import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { contactSchema } from '../schemas/contactSchema'; import styles from './ContactForm.module.css'; const ContactForm = () => { const [isSubmitting, setIsSubmitting] = useState(false); const [submitSuccess, setSubmitSuccess] = useState(false); const { register, handleSubmit, reset, formState: { errors }, } = useForm({ resolver: zodResolver(contactSchema), defaultValues: { name: '', email: '', phone: '', subject: '', message: '', }, }); const onSubmit = async (data) => { setIsSubmitting(true); try { // Simulate asynchronous API request await new Promise((resolve) => setTimeout(resolve, 1500)); console.log('Form submitted successfully:', data); setSubmitSuccess(true); reset(); } catch (error) { console.error('Submission error:', error); } finally { setIsSubmitting(false); } }; return ( <div className={styles.formContainer}> <div className={styles.formHeader}> <h2>Get in Touch</h2> <p>Have questions or want to collaborate? Fill out the form below.</p> </div> {submitSuccess && ( <div className={styles.successAlert}> Thank you! Your message has been sent successfully. </div> )} <form onSubmit={handleSubmit(onSubmit)} noValidate> {/* Name Field */} <div className={styles.formGroup}> <label htmlFor='name' className={styles.formLabel}> Full Name </label> <input id='name' type='text' {...register('name')} className={`${styles.formInput} ${errors.name ? styles.inputError : ''}`} placeholder='John Doe' /> {errors.name && ( <p className={styles.errorMessage}>{errors.name.message}</p> )} </div> {/* Email Field */} <div className={styles.formGroup}> <label htmlFor='email' className={styles.formLabel}> Email Address </label> <input id='email' type='email' {...register('email')} className={`${styles.formInput} ${errors.email ? styles.inputError : ''}`} placeholder='john@example.com' /> {errors.email && ( <p className={styles.errorMessage}>{errors.email.message}</p> )} </div> {/* Phone Field */} <div className={styles.formGroup}> <label htmlFor='phone' className={styles.formLabel}> Phone Number </label> <input id='phone' type='tel' {...register('phone')} className={`${styles.formInput} ${errors.phone ? styles.inputError : ''}`} placeholder='+628123456789' /> {errors.phone && ( <p className={styles.errorMessage}>{errors.phone.message}</p> )} </div> {/* Message Field */} <div className={styles.formGroup} style={{ marginBottom: '14px' }}> <label htmlFor='message' className={styles.formLabel}> Message </label> <textarea id='message' rows={3} {...register('message')} className={`${styles.formTextarea} ${errors.message ? styles.inputError : ''}`} placeholder='Tell us about your project requirements...' /> {errors.message && ( <p className={styles.errorMessage}>{errors.message.message}</p> )} </div> {/* Submit Button */} <button type='submit' disabled={isSubmitting} className={styles.formButton}> {isSubmitting ? 'Sending Message...' : 'Send Message'} </button> </form> </div> ); }; export default ContactForm; 

In ContactForm.jsx, we're building the core UI of our contact form. We import the styles from ContactForm.module.css and apply the generated class names directly to our JSX elements.

React contact form user interface built with Zod and CSS Modules

If you're new to CSS Modules, you can check this reference for a quick overview.

Now that our UI is ready, we use useState to track two submit-related states: isSubmitting and submitSuccess, both defaulting to false. Other standard form states (like handling onChange, field values, or onSubmit) will be managed automatically by the useForm hook.

We're also importing three key dependencies: useForm, zodResolver, and contactSchema.

react-hook-form makes form management significantly easier. Imagine having to create multiple state variables just to track every single input field! By leveraging useForm, we don't have to manually bind onChange or manage input values.
On the other hand, @hookform/resolvers acts as a bridge between react-hook-form and our Zod validation schema. In other use cases, this library can also be used with other external validation libraries, such as: Yup, Joi, or Valibot.

To use the useForm hook, we destructure { register, handleSubmit, reset, formState: { errors } } and pass an options object containing resolver and defaultValues. For the resolver property, we pass the Zod schema we created in src/schemas/contactSchema.js. In defaultValues, we define the initial value for each field in our contact form.

Notice how in ContactForm.jsx, we register each input using register() and a unique name. This makes every field available for validation and submission.

After registering the inputs, we pass our custom onSubmit handler into handleSubmit on the <form> element. We also bind isSubmitting to the submit button to disable it and update its text during submission.

At the bottom of each input field, we conditionally render error messages based on the field name. If validation fails, useForm automatically populates the errors object inside formState.

When submitted, onSubmit sets isSubmitting to true, disabling the button. Inside the try block, we simulate an API call with a Promise. On success, a log appears in the console, and submitSuccess is set to true to display the success alert. When the form submission is successful, each field in the form also gets reset to its default value. If validation fails, the catch block logs the submission error and displays error messages below the inputs. To wrap things up, the finally block resets isSubmitting back to false regardless of the outcome.

CSS Modules for Styling

Before creating the form CSS module, we need to set new CSS rules in src/main.css. So, it won't interfere with our form styling.

Open your src/main.css file. Delete all CSS rules in the file boilerplate. Then, replace it with our new rules which basically consist of cross browser styling reset and one CSS rule for our app-container class.

/* src/main.css */ /* Box sizing and reset margins/padding */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } /* Base HTML and body styling */ html, body { height: 100vh; width: 100vw; overflow: hidden; /* Prevent body scrolling */ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background-color: #f9fafb; } /* Improve text rendering */ body { text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } /* Inherit fonts for interactive elements */ input, textarea, button, select { font: inherit; } /* App container layout for 100vh centering */ .app-container { height: 100vh; width: 100vw; display: flex; align-items: center; justify-content: center; padding: 16px; } 

Now, let's create our ContactForm.module.css. This is how it looks like:

/* src/components/ContactForm.module.css */ .formContainer { width: 100%; max-width: 480px; max-height: 92vh; /* Ensures it stays within viewport height */ overflow-y: auto; /* Internal scroll only if screen is extremely small */ padding: 24px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.04); border: 1px solid #eaeaea; } .formHeader h2 { font-size: 20px; font-weight: 700; color: #111827; margin-bottom: 4px; margin-top: 0; } .formHeader p { font-size: 13px; color: #6b7280; margin-bottom: 16px; margin-top: 0; } .successAlert { padding: 10px 14px; background-color: #ecfdf5; border: 1px solid #a7f3d0; color: #065f46; border-radius: 6px; font-size: 13px; margin-bottom: 14px; } .formGroup { margin-bottom: 14px; } .formLabel { display: block; font-size: 13px; font-weight: 600; color: #374151; margin-bottom: 4px; } .formInput, .formTextarea { width: 100%; padding: 8px 12px; font-size: 13px; border: 1px solid #d1d5db; border-radius: 6px; outline: none; transition: border-color 0.2s, box-shadow 0.2s; box-sizing: border-box; } .formTextarea { resize: none; /* Prevent manual resizing that could break 100vh layout */ height: 80px; } .formInput:focus, .formTextarea:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); } .inputError { border-color: #ef4444 !important; background-color: #fef2f2; } .errorMessage { margin-top: 4px; font-size: 11px; color: #ef4444; font-weight: 500; margin-bottom: 0; } .formButton { width: 100%; padding: 10px; background-color: #4f46e5; color: #ffffff; font-weight: 600; font-size: 13px; border: none; border-radius: 6px; cursor: pointer; transition: background-color 0.2s; margin-top: 4px; } .formButton:hover:not(:disabled) { background-color: #4338ca; } .formButton:disabled { opacity: 0.6; cursor: not-allowed; } 

It's nothing fancy—just standard CSS rules to style our contact form cleanly.

Validation Test

Now, we're ready to test our form validation. Spin up your development server if it isn't running already (npm run dev).

You can test the form in a few different scenarios. For instance, try submitting the form while leaving all fields blank—you should immediately see all the validation error messages trigger.

Source:https://dev.to/ekosarya/building-an-enterprise-grade-contact-form-in-react-with-zod-and-css-modules-bi4