Nuxt Going Full-Stack: How to Streamline Form Validation?
Since weeks, I'm building a full-stack application called Orion with Nuxt and NuxtHub. Throughout this journey, I've been facing a lot of challenges, and one of them is forms validation. In this article, I'll show you how I've managed to validate forms on both the client and server sides without duplicating the validation logic, from simple text fields to complex file uploads with NuxtHub.
Orion is a community-driven collection of templates for your next project, from landing pages to complete web applications. NuxtHub is deployment and administration platform for Nuxt, powered by Cloudflare.
Create a Full-Stack Nuxt Application: A Twitch JourneyDuring this article, I'll talk about NuxtHub because it provides the primitives to build full-stack application, but even if you don't use it, you can easily follow this article. Forms will be handled without any libraries.
You can browse the example code in validate-forms-in-nuxt.
The Problem
On the web, forms are everywhere. They are the primary way to interact and ask for user input. But on the web, there is an essential rule: never trust the client (absolutely never and under no circumstances). This means that every data that comes from the client must be verified and sanitized on the server before being processed or stored in a database. A client can be a browser, a mobile app, an HTTP request, or anything else that can send data to your server, built by you or not.
When you build a traditional MPA (Multi-Page Application), only the server is responsible for the validation. For a SPA (Single-Page Application), both the client and the server are responsible for the validation to provide a better user experience. This is simplified because there is some way to provide a feeling of client-side validation with a traditional MPA, but it's not the subject of this article. BUT, even if the data is validated on the client side, it is mandatory to validate them on the server side.
The Simplest Form
Let's start our journey with the simplest form: a single text input.
<template>
<form method="post" action="/tag">
<input type="text" placeholder="Name" required>
<button type="submit">
Create Tag
</button>
</form>
</template>
Then, on the server side, I can create a route to handle the form submission:
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Save in the database...
return sendNoContent(event, 201)
})
Until now, I don't have any validation. I can send anything to the server, and it will accept it. This is a huge security issue.
Remember that the way I'm handling the validation is specific to a SPA. A traditional MPA would have added errors to flash messages and redirect back the request.
Validating the Body
In order to validate the body on the server side, I'm using Zod. Zod is a TypeScript-first schema validation with static type inference. The good part is that h3, the router and web framework that power Nitro, the server layer of Nuxt, provides a utility to validate the body: readValidatedBody
.
import { z } from 'zod'
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, z.object({
name: z.string(),
}).parse)
// Save in the database...
return sendNoContent(event, 201)
})
The utility readValidatedBody
will automatically throw an error if the body doesn't match the schema. For example, if the name is missing, the request will fail.
By default, the error will be handled by the server and throw a 500 error, but I can catch the error to return a more user-friendly error message.
The first solution is by using the method safeParse
that will not throw an error but return a result with the error if any.
import { z } from 'zod'
export default defineEventHandler(async (event) => {
const result = await readValidatedBody(event, z.object({
name: z.string(),
}).safeParse)
if (!result.success) {
throw createError({
status: 400,
message: 'Invalid data',
data: result.error.errors[0].message,
})
}
const body = result.data
// Save in the database...
return sendNoContent(event, 201)
})
The second solution is by using the parseAsync
method to attach a catch to the promise and handle the error manually.
import type { ZodError } from 'zod'
import { z } from 'zod'
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, data => z.object({
name: z.string(),
}).parseAsync(data).catch((error) => {
if (error instanceof ZodError) {
throw createError({
status: 400,
message: 'Invalid data',
data: error.errors[0].message,
})
}
}))
// Save in the database...
return sendNoContent(event, 201)
})
You could wrap the validator with a try/catch
, but I found it less elegant and readable.
Validating the Frontend
On the frontend, I can also use Zod to validate the form before sending it to the server.
<script lang="ts" setup>
import type { ZodError } from 'zod'
import { z } from 'zod'
const schema = z.object({
name: z.string({ message: 'Required' }), // I can customize the error message shown to the user
})
const state = reactive({
name: '',
})
async function onSubmit() {
try {
const data = schema.parse(state)
await $fetch('/tag', {
method: 'post',
body: data,
})
}
catch (error) {
if (error instanceof ZodError) {
throw createError({
status: 400,
message: 'Invalid data',
data: error.errors,
})
}
}
}
</script>
<template>
<form @submit.prevent="onSubmit()">
<input v-model="state.name" type="text" placeholder="Name" required>
<button type="submit">
Create Tag
</button>
</form>
</template>
The implementation is very simple and straightforward. I could save the errors in a variable to display them to the user. If you want to go further, I recommend you try the Form
component from Nuxt UI or a validation library like VeeValidate or FormKit.
Let's Refactor
Now that I have the validation logic on both the client and server sides, let's see if there is some interesting code to refactor.
The first thing I notice is that the validation logic, on the client and server sides, is the same, and that's totally normal. But what happens if I decide to change the validation logic on the server side because my API is used by another application? My frontend could be broken because the validation logic will be different. To avoid this, it could be better to only write the validator once and use it on both sides. What do you think?
I can create a file validators.ts
in the utils
folder of the client part (at the root) of a Nuxt application. This file will contain all the validators for my application and shared between the client and server.
import { z } from 'zod'
export const createTagValidator = z.object({
name: z.string({ message: 'Required' }),
})
Tip
This file can be used on the server but has been created on the client part so you need to explicitly import everything you need to avoid renderer issues.
This validator contains the message for the error to help the user understand what's wrong.
Thanks to the auto-import feature of Nuxt, I can easily use it on the client:
<script lang="ts" setup>
const schema = createTagValidator
// ...
</script>
<template>
<form>
<!-- ... -->
</form>
</template>
Then, you can also use it on the server side by importing it:
import { createTagValidator } from '~/utils/validators'
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, createTagValidator.parse)
// Save in the database...
return sendNoContent(event, 201)
})
Now, I can safely change the validation logic without breaking the frontend since they follow the same rules, and I don't have to duplicate the code.
Handling Files
For this part, I'll show you two different ways to validate files on the server. The first one will use Zod and some refinements, and the second one will use the ensureBlob
utility from NuxtHub.
This validation is not feature complete and should be improved to handle more cases.
With Zod
For this second part, I'll show you how to upload a file to the server. First, let's add our input to the form:
<template>
<form action="/api/image" enctype="multipart/form-data" method="post">
<input type="file" accept="image/png, image/jpeg, image/jpg" required>
<button type="submit">
Create Tag
</button>
</form>
</template>
Then, I can handle the file on the server side, but it's a bit more complex because I send a formData
and h3 does not provide a utility like readValidatedFormData
. So, I need to handle it manually.
First, I will read the formData
using the readFormData
utility, then I will extract the image
from the formData
and validate it using Zod.
import { any, object } from 'zod'
export default defineEventHandler(async (event) => {
const formData = await readFormData(event)
const image = formData.get('image')
await object({
image: any()
.refine(image => image instanceof File, { message: 'Image must be a file' })
.refine(image => image.size < 1024 * 1024, { message: 'Image must be less than 1MB' })
.refine(image => image.type.startsWith('image/'), { message: 'Image must be an image' })
.refine(image => ['image/png', 'image/jpeg', 'image/jpg'].includes(image.type), { message: 'Image must be a JPEG or PNG' })
}).parseAsync({ image }).catch((error) => {
throw createError({
status: 400,
message: 'Invalid image',
data: error.errors[0].message
})
})
// Save the image to a database or a file system
return sendNoContent(event, 201)
})
On the client side, I can also validate the file using the same technique as before and by sharing the validator between the client and server sides.
First, I need to move the validator to the utils/validators.ts
file:
import { any, object } from 'zod'
export const createImageValidator = object({
image: any().refine(image => image instanceof File, { message: 'Image must be a file' }).refine(image => image.size < 1024 * 1024, { message: 'Image must be less than 1MB' }).refine(image => image.type.startsWith('image/'), { message: 'Image must be an image' }).refine(image => ['image/jpeg', 'image/png'].includes(image.type), { message: 'Image must be a JPEG or PNG' })
})
Then, I can use it when the form is submitted to avoid sending invalid data to the server:
<script lang="ts" setup>
async function onImageSubmit(event: Event) {
const target = event.target as HTMLFormElement
const formData = new FormData(target)
const image = formData.get('image')
try {
await createImageValidator.parseAsync({ image })
await $fetch('/api/image', {
method: 'POST',
body: formData,
})
}
catch (error) {
console.error(error)
}
}
</script>
<template>
<div>
<h1>Validate Forms with Nuxt</h1>
<h2>
Image
</h2>
<form enctype="multipart/form-data" @submit.prevent="onImageSubmit($event)">
<input type="file" name="image" accept="image/png, image/jpeg, image/jpg">
<button type="submit">
Submit
</button>
</form>
</div>
</template>
If the validation fails, the request will not be sent to the server, and the error will be displayed in the console. Of course, you can handle the error as you want, like using a toast
or showing an error message under the related input.
On the server, I can replace the validation logic with the validator:
import { saveImageValidator } from '~/utils/validators'
export default defineEventHandler(async (event) => {
// ...
await saveImageValidator.parseAsync({ image })
// ...
})
With NuxtHub
NuxtHub comes with a utility named ensureBlob
that can be used to check the file size and type. This utility is very useful because it will automatically throw an error if the file is not valid, but can only be used on the server. This means that I will have a Zod schema on the client and a NuxtHub utility on the server. Use constants to share validation limits between the Zod Schema and the NuxtHub utility.
export default defineEventHandler(async (event) => {
const formData = await readFormData(event)
const image = formData.get('image') as Blob
ensureBlob(image, {
maxSize: '1MB',
types: ['image/png', 'image/jpeg', 'image/jpg']
})
// Save the image to a database or a file system
return sendNoContent(event, 201)
})
Text and Blob within the Same Form
Until now, I've only one type of input in my form, text or file. But what if I want to have both in the same form?
You need to know that you absolutely need to use a formData
to send the form to the server due to the file input.
The form is still very simple:
<template>
<form action="/api/text-image" enctype="multipart/form-data" method="post">
<input type="text" name="name" placeholder="Name" required>
<input type="file" name="image" accept="image/png, image/jpeg, image/jpg" required>
<button type="submit">
Create Tag
</button>
</form>
</template>
Now, I can create a validator using Zod:
import { any, object, string } from 'zod'
export const createValidator = object({
name: string({ message: 'Required' }),
image: any()
.refine(image => image instanceof File, { message: 'Image must be a file' })
.refine(image => image.size < 1024 * 1024, { message: 'Image must be less than 1MB' })
.refine(image => image.type.startsWith('image/'), { message: 'Image must be an image' })
.refine(image => ['image/png', 'image/jpeg', 'image/jpg'].includes(image.type), { message: 'Image must be a JPEG or PNG' })
})
This validator can be used as before, on both the server and client.
If you prefer to use the ensureBlob
utility from NuxtHub, I recommend you split the validator into two parts: one for the text and one for the image. Both the text and the image will be used on the client by merging them into a single object, and only the text will be used on the server.
import { any, object, string } from 'zod'
export const createTextValidator = object({
name: string({ message: 'Required' }),
})
export const createImageValidator = object({
image: any()
.refine(image => image instanceof File, { message: 'Image must be a file' })
.refine(image => image.size < 1024 * 1024, { message: 'Image must be less than 1MB' })
.refine(image => image.type.startsWith('image/'), { message: 'Image must be an image' })
.refine(image => ['image/png', 'image/jpeg', 'image/jpg'].includes(image.type), { message: 'Image must be a JPEG or PNG' })
})
export const createValidator = createTextValidator.merge(createImageValidator)
On the client, I will use the createValidator
to validate the form before sending it to the server:
<script lang="ts" setup>
async function onSubmit(event: Event) {
const target = event.target as HTMLFormElement
const formData = new FormData(target)
const name = formData.get('name')
const image = formData.get('image')
try {
await createValidator.parseAsync({ name, image })
await $fetch('/api/text-image', {
method: 'POST',
body: formData,
})
}
catch (error) {
console.error(error)
}
}
</script>
On the server, I will use the createTextValidator
to validate the text and the ensureBlob
utility to validate the image:
import { createTextValidator } from '~/utils/validators'
export default defineEventHandler(async (event) => {
const formData = await readFormData(event)
const name = formData.get('name')
const data = await createTextValidator.parseAsync({ name }).catch((error) => {
throw createError({
status: 400,
message: 'Invalid data',
data: error.errors[0].message
})
})
// Save the text in the database...
const image = formData.get('image') as Blob
ensureBlob(image, {
maxSize: '1MB',
types: ['image/png', 'image/jpeg', 'image/jpg']
})
// Save the image to a database or a file system
return sendNoContent(event, 201)
})
For longer forms, I could use a loop to extract every field from the form and validate them. In this loop, I can handle specific cases like arrays of multi-select fields or files or skipping some fields (that could be validated using ensureBlob
).
const data = {}
for (const [key, value] of formData.entries()) {
if (key === 'moduleId[]') {
data[key] = formData.getAll(key)
continue
}
else if (key === 'image') {
continue
}
else {
data[key] = value
}
}
Conclusion
And voilà! You are now able to validate forms on both the client and the server without duplicating the validation logic. This is a huge step forward in building a full-stack application with Nuxt. I hope this article will help you to build your next application with confidence. You should definitely check the NuxtHub documentation to learn more about the platform and the power they provide to build full-stack applications with Nuxt.
Please, let me know if you have any questions or feedback. I could make some mistakes, so don't hesitate to correct me. I'm always looking for ways to improve my code and my articles.
Thanks for reading!
Thanks for reading! My name is Estéban, and I love to write about web development.
I've been coding for several years now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.
If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.
I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below—it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!