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 generate
script 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:
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
- Importing Zod: First, the Zod library must be imported in order to use its schema and validation functions.
- Defining the schemas:
formSchema
defines the expected object structure usingz.object()
. The object contains three fields (email
,age
andmessage
), each validated by Zod's chained built-in methods:- 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’semail()
function. 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.- The
message
field expects a string (z.string()
) with a minimum length of 10 characters (min(10)
).
- The
- 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.
- Defining Types: The TypeScript types are derived from the schema (
formSchema
) with Zod’sz.infer()
helper function.- The types for the form data will be defined in
FormData
. They are automatically generated byz.infer<typeof formSchema>
from the schema. Any changes to the schema will be reflected. FormErrors
: This type defines an error object where keys (likeemail
,age
, andmessage
) can map to error messages. Since errors are only present when validation fails, each field is made optional usingPartial<>
. TheRecord<keyof FormData, string>
pattern maps each form field to astring
error message, if present. The resulting object would look like this:
- The types for the form data will be defined in
{
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
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:
<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<input>
element. - The
inputType
prop determines thetype
attribute of the<input>
. It is a string and, in this example, can be set to eitheremail
ornumber
. - 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:
<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:
<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 ifsuccess
istrue
)error
: contains information about the error, such as the error messages (only ifsuccess
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 path
to 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:
<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:
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 POST
suffix 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.successis
false), 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
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.