Loading...

Implement Full-Stack Form Validation in Nuxt 3 with Zod

Last update: 11/10/2024
Title image for Implement Full-Stack Form Validation in Nuxt 3 with Zod

Introduction

When applications provide user forms, validating the input is essential to ensure data accuracy and security. Although server-side validation is crucial to prevent malicious inputs, adding client-side validation is best practice and improves the user experience by catching errors instantly. In most cases, this requires implementing separate validation code on both the frontend and backend, leading to potential inconsistencies and redundant testing. Since Nuxt is a full-stack framework, you can simplify this process by defining validation functions once and using them on both the client and the server-side (assuming this is desired). This guide demonstrates how to set up a shared validation for user forms using Nuxt 3 and Zod, a schema-based validation library.

In This Guide

In this guide, we’ll walk through the process of building a form in Nuxt 3 that validates data both client-side and server-side using Zod. After installing Zod and defining the validation schemas, a basic form is created that collects email, age, and message fields and implements client-side validation. Next, a backend API is implemented to revalidate the data server-side. We’ll also cover how to handle errors, display feedback to users, and ensure data consistency across the client and server. The complete source code is linked at the end of this guide.

The approach in this guide is based on Nuxt’s build script with a Nuxt server running in the backend. It does not work with the generatescript since this aims for static hosting provides without a Nuxt server running in the backend.

Note: This tutorial aims to demonstrate a concept for full-stack validation in Nuxt 3. It does not focus on fine-grained validation or error-handling on a production level.

Setup

  • Nuxt 3.13.2
  • Zod 3.23.8
  • Vite 5.5.0
  • Node.js 20.14.0

Prerequisites

  • Familiarity with Vue 3’s Composition API and the <script setup> syntax
  • Basic understanding of Nuxt 3 and the server/ directory
  • Knowledge of HTTP requests and the fetch method
  • Basic understanding of Zod
  • Node.js is installed on your machine
  • A fresh Nuxt 3 project setup

Setting up zod

Zod is a popular TypeScript-first schema-based validation library. It lets you define schemas to validate and transform JavaScript objects, ensuring that data conforms to expected types. Start by installing Zod in a (new) Nuxt project:

npm install zod

Next, create a Validators.ts file in the utils/ directory (or any preferred location for utility functions). The following schema defines the types for the contact form input fields:

Validators.ts
import { z } from "zod"

// Define zod schemas for data validation.
export const formSchema = z.object({
    email: z.string().email({ message: "Please enter a valid E-Mail address." }),
    age: z.number({ message: "Enter a number." }).int().positive({ message: "Enter a number greater than 0." }),
    message: z.string().min(10, { message: "Please enter a message." }),
})

// Define types.
export type FormData = z.infer<typeof formSchema>
export type FormErrors = Partial<Record<keyof FormData, string>>

Explanation of Validators.ts

  1. Importing Zod: First, the Zod library must be imported in order to use its schema and validation functions.
  2. Defining the schemas: formSchema defines the expected object structure using z.object(). The object contains three fields (email, age and message), each validated by Zod's chained built-in methods:
    1. The email field is defined as a string using z.string(). The chained email() method validates that the string is in a proper email format, returning the specified error message if validation fails. Please refer to the source code on GitHub for more details about Zod’s email() function.
    2. age expects a number of a numeric type (z.number()) and being an integer (int()) larger than zero (positive()). If any of the validation methods fail, the respective error message is returned.
    3. The message field expects a string (z.string()) with a minimum length of 10 characters (min(10)).
  3. Chaining Methods: The Zod validation methods can be chained and are executed in sequence. If one validator fails, validation stops, and the associated error message is returned.
  4. Defining Types: The TypeScript types are derived from the schema (formSchema) with Zod’s z.infer() helper function.
    1. The types for the form data will be defined in FormData. They are automatically generated by z.infer<typeof formSchema> from the schema. Any changes to the schema will be reflected.
    2. FormErrors: This type defines an error object where keys (like email, age, and message) can map to error messages. Since errors are only present when validation fails, each field is made optional using Partial<>. The Record<keyof FormData, string> pattern maps each form field to a string error message, if present. The resulting object would look like this:
{ 
  email?: string, 
  age?: string,
  message?: string
}

These schema and types can now be used in Vue components and server-side API routes to test the form data.

Building Reusable Form Components

Next, we’ll build Vue components for the form fields: a component for basic input fields and a component for longer text. These components handle user input and display the validation error messages.

A third <ContactForm> component combines them to implement the fields for email, age and message. It also manages the validation process, hands the error messages to the child component, and submits data via an HTTP POST request to a server-side API.

Implementing <ContactFormInput>

We start with the component for the input fields. Create a file ContactFormInput.vue in the components/ directory with the following code:

ContactFormInput.vue
<script setup lang="ts">
//Define props.
const props = defineProps<  {
  id: string
  inputType: string
  label: string
  error?: string
}>();

// Model binding.
const modelValue  = defineModel<string | number>();
</script>

<template>
  <div class="input">
    <label :for="props.id">{{ props.label }}</label>
    <input :id="props.id" :type="props.inputType" v-model="modelValue"/>
    <div class="error">
      <p v-if="props.error">{{ props.error }}</p>
    </div>
  </div>
</template>

This component defines the main elements for an input field: a <label> (line 16), an <input> element (line 17), and a paragraph for displaying an error message (line 19). The component receives the following props which are used in the template:

  • The id prop sets the identifier for the ` element.
  • The inputType prop determines the type attribute of the <input>. It is a string and, in this example, can be set to either email or number.
  • The label prop provides text for the <label> element
  • The error prop will contain the error message in case the user inputs invalid data.

Since the validation happens in the parent component (<ContactForm>) we use defineModel<T> for a two-way binding of the <input>’s value (v-model="modelValue“) and the parent component (as shown below). This approach centralizes validation in <ContactForm> while keeping the input component flexible and reusable.

Implementing <ContactFormTextarea>

Next, create ContactFormTextarea.vue in the components/directory for the message input:

ContactFormTextarea
<script setup lang="ts">
//Define props.
const props = defineProps<  {
  id: string
  label: string
  error?: string
}>();

// Model binding.
const modelValue  = defineModel<string>();
</script>

<template>
  <div class="textarea">
    <label :for="props.id">{{ props.label }}</label>
    <textarea :id="props.id" v-model="modelValue"></textarea>
    <div class="error">
      <p v-if="props.error">{{ props.error }}</p>
    </div>
  </div>
</template>

This component is similar to the <ContactFrominput> component but uses <textarea> for multi-line input. Note that the inputType property is not required here.

Creating the Main Contact Form Component

The <ContactForm> components implements the three input fields and handles the form submission, the client-side validation and the API request.

Create a new file ContactForm.vue in components/ with the following <script setup> code:

ContactForm.vue
<script setup lang="ts">
import type { FormData, FormErrors } from "~/utils/Validators";
import { z } from "zod";

// Init form data and error messages.
const initialFormData: FormData = {
  age: 0,
  email: "",
  message: ""
};

const formData = ref<FormData>({ ...initialFormData });
const formErrors = ref<FormErrors>({});
const statusMessage = ref<string | null>(null);

// Validation.
const validateFormData = (): boolean => {
  // Parse form data without throwing an error.
  const result: z.SafeParseReturnType<FormData, FormData>
      = formSchema.safeParse(formData.value);

  // Debug: Log validation results client-side.
  // console.log("Results client: ", result);

  // Display errors.
  if (!result.success) {
    formErrors.value = result.error.errors.reduce((acc: FormErrors, error: any) => {
      const key = error.path[0] as keyof FormData;
      acc[key] = error.message;
      return acc
    }, {} as FormErrors)
    return false;
  }

  // Reset errors if data are valid.
  formErrors.value = {};
  return true;
};

// Handle form submission.
const onSubmit = async () => {
  // Reset statusMessage.
  statusMessage.value = null;

  // Check if validation failed.
  if (!validateFormData()) return;

  // If the validation is successful, send data to the server.
  try {
    const response: Response = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(formData.value)
    });

    // Response handling.
    const result = await response.json();
    statusMessage.value = result.message;

    // Reset the form values.
    if (result.statusCode === 200) {
      formData.value = { ...initialFormData };
    }
  } catch {
    statusMessage.value = "There was an unexpected error.";
  }
};
</script>

The initial form values are then defined in lines 5 to 9. In line 11, the form data (formData) is initialized with these values, using the FormData type as defined in Validators.ts. Similar, the formErrors object is set up in line 12 as en empty object to store any validation error messages. The statusMessage will hold a status message from the HTTP request and can be either of type string or null. All three are defined as reactive values using ref().

Further you see two functions validateFormData() and onSubmit().

validateFormData() contains the logic for validating the form data and for assigning the error messages. The formSchema.safeParse function from Zod takes the form values as argument and validates them against the schema. The function returns and object with

  • success: a boolean indicating validation passed (true) or failed (false).
  • data: parsed data (only if success is true)
  • error: contains information about the error, such as the error messages (only if success is `false).

Note: Unlike z.parse, z.parseSafe does not throw an error on validation failure, but safely returns the validation outcome without throwing an error.

If the validation fails (results.success is false), formErrors is populated with the error messages from result.error.errors, an array of objects, where each includes:

  • path: the form field that failed validation.
  • message: the corresponding error message.

Using reduce(), we accumulate these errors into an object of type FormErrors ({} as FormErrors) by mapping each pathto its corresponding message, by iterating over the error objects, extracting the key of the failed field according to the type defined in FormData (line 26). The error message is then added to the accumulator object acc of type FormData under the respective key (line 27). The validateFormData() function then returns false to indicate validation failure. If validation succeeds, formErrors is cleared (line 34).

The onSubmit function triggered on form submission, performing validation and, if successful, sending a POST request to the API. First, it resets the status message (statusMessage) to null (line 41) and calls the validation (line 44). If validation fails, onSubmit() exits immediately since validateFormData has already populated formErrors with error messages.

If the validation succeeds, the data (formData.value) is stringified and send to the API endpoint (api/contact) as a POST request using fetch. Regardless of the response status, statusMessage is updated in line 56. If the API returns a status code 200, the form data was successfully processed in the backend, and the form values are reset to their initial values (line 60). In case a network or any other error occurs, the satusMessage is manually set with an error message (line 63).

Next, we add the template for the <ContactForm> component:

ContactForm.vue
<script setup lang="ts">
// …
</script>

<template>
  <form @submit.prevent="onSubmit" novalidate>
    <ContactFormInput
      v-model="formData.email"
      id="email"
      inputType="email"
      label="E-Mail"
      :error="formErrors.email" />

    <ContactFormInput
        v-model="formData.age"
        id="age"
        inputType="number"
        label="Age"
        :error="formErrors.age" />

    <ContactFormTextarea
      v-model="formData.message"
      id="message"
      label="Message"
      :error="formErrors.message" />

    <button type="submit">Submit</button>
    <p class="status">{{ statusMessage }}</p>
  </form>
</template>

<style>
// Simple style ommited here.
</style>

The form prevents the browser’s default submission behavior (@submit.prevent) and built-in field validation (novalidate) as we want to control it manually.

The first <ContactFormInput> component is used to implement the email field. Using the v-model directive, we utilize two-way binding between formData.email in <ContactForm> and the value of the input field in <ContactFormInput> field. This binding is essential, since the parent component (<ContactForm>) handles the validation. We also pass properties to configure the field: an identifier email, the input type email, a label E-Mail and a reactive prop for the error message from formErrors.email.

The second <ContactFormInput> component implements the field for the age accordingly but is of type number. The <ContactFormTextarea> component implements a <textarea> element for an arbitrary message. Its props are similar to those of <ContactFormInput>, except it does not require an input type. At the end you find a submit button (line 27) and a paragraph displaying the status message from the HTTP request (line 28). See the full code of <ContactForm> on GitHub.

Setting Up the Server-Side API with Validation

Finally, we need to implement the API route to handle form submissions. We want this API to be accessible at the path /api/contact. In Nuxt, API endpoints are organized in the server/api/ directory. To set up this route, create a new file contact.POST.ts in server/api/ and add the following code:

contact.POST.ts
import { formSchema } from "~/utils/Validators";

// Define POST contact endpoint.
export default defineEventHandler(async (event) => {
    // Check http header.
    // ...

    // Read the HTTP body and validate it according to the zod form schema.
    const result = await readValidatedBody(event, body => formSchema.safeParse(body));

    // Debug: Log validation results server-side.
    // console.log("Results server: ", result);

    // Handle validation error.
    if (!result.success) {
        throw createError({
            statusCode: 400,
            statusMessage: "Bad Request",
            message: " Validation failed",
        });
    }

    // Process successfully validated data here.
    //...

    // Return status code 200.
    return { statusCode: 200, message: "Data processed successfully" };
});

The POSTsuffix in the filename indicates, that this endpoint is designated for handling HTTP POST requests. Since we want to validate the data on the server-side again, we first need to import the formSchema. Note that this import is necessary because functions in the utils/ directory are only auto-imported in the client-side Vue app.

The logic is implemented in the defineEventHandler() function, which receives a callback function (an async function in this case, as we will make use of asynchronous operations). The function takes the event as parameter that encapsulates all the data from the HTTP request. In a real life application, you would typically start with validating the HTTP headers. However, for this example, we proceed directly to reading the request body.

readValidatedBody() is a Nuxt helper function that reads the data from the HTTP request body and validates it against a specified schema. It takes the event object as the first argument and a validation function as the second, in this case body => formSchema.safeParse(body). The safeParse() method returns an object with a success property indicating validation success (true) or failure (false). If validation fails (result.successisfalse), an error is thrown with createError(), which sends a 400 status code, an appropriate status message (statusMessage`), and an error message, which will be displayed in the component (in line 93) to inform the user.

If validation is successful, the data can then be processed as needed (line 24). To indicate the data is processed successfully, a JSON object can be returned with a200 status code and a success message, which will be displayed in the <ContactForm> component to inform the user.

Testing the Form

Insert the <ContactForm> component to your app.vue file, build the application with npm run build and preview it with npm run preview at http://localhost3000. Test the form by entering different values to ensure both client- and server-side validations work as expected.

To test server-side validation independently, temporarily disable client-side validation by commenting out line 46 in the <ContactForm> component. This allows invalid data to bypass client-side checks, reaching the backend, where it should trigger a validation error and return an error message. This test confirms that the backend correctly rejects improperly formatted data, even when client-side validation is skipped.

Summary

This guide demonstrates how to implement robust form with a shared validation logic using Nuxt 3’s full-stack capabilities and the Zod library, ensuring data consistency across both client and server. It covers the setup of Zod schemas, reusable form components, and the creation of a server-side API endpoint to validate data upon submission. This approach streamlines the validation workflow, minimizing redundant code and enhancing security.

Report a problem (E-Mail)