Next.js is a popular React framework known for building end-to-end web applications. It enables developers to manage both the front-end and back-end of a project within a unified codebase, simplifying development and maintenance.
When building web APIs, input validation becomes crucial because your routes may be consumed by various clients. These clients could range from the React UI you’re developing to external systems accessing your public APIs. Ensuring that your API only processes valid data is important for maintaining security and reliability, especially when inputs come from sources beyond your control.
Zod is a popular library in the JavaScript and TypeScript ecosystem for schema validation. It offers an intuitive and expressive API, allowing you to define the structure (or schema) of your data and enforce validation rules. Zod is widely integrated with other tools and libraries, such as react-hook-form
for form handling, tRPC
for building type-safe APIs, and zod-to-json-schema
for generating JSON Schema definitions, making it a versatile choice for developers.
Why Validate API Inputs?
Input validation is important for enhancing the reliability and security of your software. It helps safeguard your application by preventing common vulnerabilities like injection attacks, cross-site scripting (XSS), and denial-of-service (DoS) exploits.
Beyond security, validation also prevents application errors caused by invalid or unsupported data. Without proper checks, your API might encounter unexpected values or incorrect types, leading to runtime errors, application crashes, or unpredictable behavior. Ensuring only valid data reaches your code minimizes these risks and keeps your application running smoothly.
How can I use Zod?
The key features of Zod are schema declaration, parsing, and type inference. Let’s see how to use Zod and how to leverage them.
Let’s start by installing zod
into your project.
npm install zod
With Zod we can create the schema definition of an object, like this one:
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email format'),
age: z.number().int().gte(18, 'Must be 18 or older'),
});
This code defines the schema of an object with a mandatory name
property, a valid email
address, and a numeric integer value age
, greater than or equals to 18.
With Zod you can also define the error message when the value provided doesn’t satisfy the conditions.
Now that we have a schema definition, let’s parse an object to validate it.
const user = {
name: 'Luca',
email: 'hey@shipped.club',
age: 38
}
const validatedUser = userSchema.parse(user)
If the object provided is not correct, .parse()
throws an error.
If you want to avoid throwing an error, you can use .safeParse()
that returns an object instead:
userSchema.safeParse(12);
// => { success: false; error: ZodError }
userSchema.safeParse(user);
// => { success: true; data: { ...user } }
Finally, you can infer the TypeScript types from your schema!
type User = z.infer<typeof userSchema>;
How to use Zod in my Next.js App Route
Now, let’s create a Next.js API POST route that receives a body payload that we want to validate.
export async function POST(request: NextRequest) {
try {
// Parse the request body
const body = await request.json();
const data = userSchema.parse(body);
// Business logic here (e.g., save to database)
return NextResponse.json(
{ message: 'Registration successful', data },
{ status: 200 }
);
} catch (error) {
if (error instanceof z.ZodError) {
// Return validation errors
return NextResponse.json(
{ message: 'Validation error', errors: error.errors },
{ status: 400 }
);
}
// Handle unexpected errors
return NextResponse.json(
{ message: 'Something went wrong', error: error.message },
{ status: 500 }
);
}
}
This POST method receives a JSON payload, it reads and parses it through the Zod schema that we defined.
If the validation succeeds, we can process the data, otherwise we return a validation error.
This way we can ensure that our business logic uses only valid data.
Zod works particularly well with Next.js, because you can define the Schema for the data expected on the API routes, and reuse the same schema and types when you need to build the same object on the frontend, to perform the request.
Here’s an example of a React component that sends a POST request, using userSchema to validate the values provided by the user in a form.
import React, { useState } from 'react';
// import the schema and inferred type created with Zod
// 👇👇👇
import { userSchema, type User } from '../schemas/userSchema';
const RegisterForm: React.FC = () => {
const [formData, setFormData] = useState<User>({
name: '',
email: '',
age: 18, // Default age
});
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
// Update state dynamically based on input name
setFormData((prev) => ({
...prev,
[name]: name === 'age' ? Number(value) : value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Validate the form data using the Zod schema
// 👇👇👇
userSchema.parse(formData);
// Send the POST request
const response = await fetch('/api/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (response.ok) {
setSuccess('Registration successful!');
setError(null);
} else {
const errorResponse = await response.json();
setError(errorResponse.message || 'Failed to register');
setSuccess(null);
}
} catch (err: any) {
if (err.errors) {
// get all errors from Zod, and concatenate them in a string
setError(err.errors.map((error: any) => error.message).join(', '));
} else {
setError('An unexpected error occurred');
}
setSuccess(null);
}
};
return (
<div>
<h1>Register</h1>
{error && <p style={{ color: 'red' }}>{error}</p>}
{success && <p style={{ color: 'green' }}>{success}</p>}
<form onSubmit={handleSubmit}>
<div>
<label>
Name:
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
/>
</label>
</div>
<div>
<label>
Email:
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
</label>
</div>
<div>
<label>
Age:
<input
type="number"
name="age"
value={formData.age}
onChange={handleChange}
/>
</label>
</div>
<button type="submit">Submit</button>
</form>
</div>
);
};
export default RegisterForm;
To test your validation, you can use cURL in the terminal or Postman if you prefer to use a user interface.
With cURL:
curl -X POST <http://localhost:3000/api/register> \\
-H "Content-Type: application/json" \\
-d '{"name": "Luca", "email": "hey@shipped.club", "age": 38}'
Conclusions
I encourage you to use Zod extensively in your codebase, and to use it to define your types whenever you need validation or when you expose an interface to another client of your architecture, like a private or public API.
Zod is extremely useful and expressive, it allows you to define complex data types and validation rules. You can read the full documentation on GitHub.
I hope this article was useful, and helped you level up your coding skills.
Happy shipping! 🚀
Luca