Loading...

Full-Stack Form Validation in Nuxt 4 with Zod 4

Last update: 1/7/2026
Title image for full-stack form validation in nuxt 4 with zod 4

Introduction

Validating user inputs is crucial—not only on the server-side, but also on the client-side. This guarantees improved usability by catching errors where they occur and follows best practices.

To avoid implementing and maintaining separate validation logic for the client- and the server-side, you can make use of Zod, a schema-based validation framework, and the full-stack capabilities of Nuxt 4{:target="_blank“}, by sharing the validation schema across both sides.

In This Guide

We build a form in Nuxt 4 and validate the data with Zod 4 on both the client and the server-side by sharing a single validation schema across both sides. The form will contain an e-mail, an age, and a message field. The user input will then be validated on the client side. Next, a corresponding backend API will be implemented that receives and revalidates the data. The source code is available on GitHub and linked at the end of this article.

The approach described here only works with the Nuxt build script! It does not work with generate, as a running server is necessary to process backend requests.

Setup

  • Nuxt 4.2.1
  • Vue 3.5.25
  • Zod 4.1.13
  • Node.js 22.13.1

Prerequisites

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

Setting up a Zod 4 Schema

Zod is a TypeScript-first and schema-based validation library. It validates data according to a predefined schema using type inference. Before we set up the form components, we implement the validation schema that describes the structure of the data we expect from the form.

After creating a clean Nuxt 4 project, install Zod 4 with

npm install zod

at your project root and make sure it is set up with the expected version!

Since we want to share the validation schema between client and server, we create a validators.ts file in shared/utils/:

validators.ts
import { z } from "zod"

// Define zod schemas for data validation.
export const formSchema = z.object({
    email: z.email({ pattern: z.regexes.rfc5322Email, 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:

  1. Import: First, import the Zod library in order to use its schema and validation functions.
  2. Schema definition: formSchema defines the expected object structure (z.object()). The object contains three fields (email, age and message), each validated by Zod validation methods. The validation methods can be chained and are executed in sequence. If one validator fails, the validation stops, and the associated error message is returned:
    1. A valid mail format from the email field is defined by providing a pattern to the email() validation method. Here, the classic RFC 5322 pattern is used (check the documentation for available patterns). An error message is returned if the validation fails.
    2. age expects a positive integer (z.number().int().positive()). If any of the validation methods fail, the corresponding error message is returned.
    3. The message field must be a string with a minimum length of 10 characters (z.string().min(10)).
  3. Type definition: The TypeScript types for validation related objects are derived from the schema (formSchema) with Zod’s z.infer() helper function.
    1. FormData defines the types for the form data . They are automatically generated by z.infer<typeof formSchema> from the schema. This ensures type safety and consistency.
    2. FormErrors: This type defines an error object and maps keys (email, age, and message) to the corresponding error message. 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 an optional error message of type string. Example structure of FormErrors:
{ 
  email?: string, 
  age?: string,
  message?: string
}

The schema and types can now be reused across client-side Vue components and the server-side API routes to validate the form data.

Building the Form Components

A <ContactForm> component will contain two input fields for the e-mail and the age and one textarea for a message. The component manages the validation process, the error handling, and form submission via an HTTP POST request to a Nuxt API endpoint.

*** Implementing the Input Field Component This component will hold an <input> element and can be used for the e-mail and the age fields.

Create a new file ContactFormInput.vue in the app/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>

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

  • id: Sets the identifier for the <input> element.
  • inputType: Determines the type attribute of the <input> and - in this example - can be set to either email or number.
  • label: Provides text for the <label> element.
  • error: Will contain the error message in case the user input is not valid.

The validation logic is handled in the parent component, <ContactForm>. Therefore, we use defineModel<T> for a two-way binding of the <input> value (v-model="modelValue“) and the parent component (defined below). This approach centralizes validation in <ContactForm> while keeping the input component flexible and reusable.

Implementing the Message Field Component

In app/components/ create a new file ContactFormTextarea.vue for the message field:

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>

The component is similar to <ContactFrominput> but uses <textarea> for multi-line text. Note: The inputType property is not required here.

Contact Form Component and Client-side validation with Zod 4

The <ContactForm> is the main component for the form and implements the three input / textarea fields. It also handles the form submission, the client-side validation, and the request to the API.

Create a new file ContactForm.vue in app/components/:

ContactForm.vue
<script setup lang="ts">
import type { FormData, FormErrors } from "~~/shared/utils/validators";
import { formSchema } from "~~/shared/utils/validators";

// Initial 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: ReturnType<typeof formSchema.safeParse> = formSchema.safeParse(formData.value);

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

  // Display errors.
  if (!result.success) {
    formErrors.value = result.error.issues.reduce((acc: FormErrors, issue: any) => {
      const key = issue.path[0] as keyof FormData;
      acc[key] = issue.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 API.
  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 validation schema and the inferred types are imported at the top. The initial form values are then defined in lines 6 to 10. They are used in line 12 to initialize the object holding the form data (formData), using the inferred FormData type from the definition in validators.ts. The formErrors object is set up in line 13 similarly but as an empty object to store potential validation error messages. The statusMessage is for the status message from the HTTP request and can be either of type null or string. All three are reactive values (ref()) to manipulate their state.

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

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

Note: Unlike parse(), parseSafe() does not throw an error on validation failure! Instead, it safely returns the validation outcome without throwing an error.

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

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

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

When submitting the form, the onSubmit() function is called to execute validation and, if successful, send a POST request to the API. First, the status message is set to null (line 42), and the data is validated in line 45. If validation fails, onSubmit() exits immediately since validateFormData has already populated formErrors with the error messages.

On validation success, the data (formData.value) is stringified and sent to the API endpoint (api/contact) as a POST request with fetch. Regardless of the response status, statusMessage is updated in line 57. If the API returns a status code 200, the form data was successfully processed in the backend. The form values are then reset to their initial values (line 61). In case of a network or any other unexpected error occurring, the error message is manually set (line 64).

Next, add the template to <ContactForm>:

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 with @submit.prevent and browser-native validation is disabled with novalidate as validation is handled manually.

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

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

Implementing the Server-Side API form Validation using Nuxt 4 API

To process and validate form data on the server, create an API endpoint at /api/contact. In Nuxt 4, API endpoints are defined in the server/api/ directory. Therefore, create a new file contact.post.ts in server/api/:

contact.post.ts
import { formSchema } from "~~/shared/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 post.ts suffix in the contact.post.ts indicates that this endpoint handles HTTP POST requests. Similar to the <ContactForm> component, we first need to import the formSchema for the validation to work.

The logic for the route is implemented in the defineEventHandler() function and receives a callback function (an async function in this case, as we will make use of asynchronous operations). The function takes the event as a parameter which encapsulates all the data from the HTTP request. In a real application, you would typically start with validating the HTTP headers. However, for simplicity, we skip this part and continue with the request body.

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

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

Testing the Form

Insert the <ContactForm> component in app.vue, build the application with npm run build and preview it with npm run preview at http://localhost3000.

Test the form by entering different values into the fields and ensure both client- and server-side validation work as expected.

To test server-side validation independently, temporarily disable client-side validation by commenting out the validation call in line 45 in the <ContactForm> component. This allows sending invalid data to the backend, where it triggers a validation error and returns an error message. The test confirms that the backend rejects improperly formatted data, even when client-side validation is skipped.

Summary

This guide demonstrates how to implement a robust form with shared validation logic using the full-stack capabilities of Nuxt 4 and the Zod validation library (version 4), ensuring data consistency across both the client and the server. It covers the setup of Zod validation schemas, reusable form components, and the implementation of a server-side API endpoint to validate submitted form data. This approach optimizes the validation workflow, reduces code duplication, and improves security.

Report a problem (E-Mail)