Protect your Next.js API with Zod
Zod is a Typescript-first validation library for parsing data-structures and validate they respect your schema. In this blog post, we show how to validate a Next.js API's body payload with Zod.
The first step towards writing security API endpoints with Next.js should be make sure that the server receives the expected data structure from the clients.
Various libraries help us validate the clients' payloads reasonably well, such as Joi and class-validator, which I have used extensively in the past.
The library that impressed me the most lately has been Zod, a great validation library for working with Typescript written by colinhacks.
Let's take a look at how we can use Zod for validating the payloads coming
from a client, improving the security of your application, and helping you
strong-typing your apps without as
hacks.
Getting to Know Zod
Installing Zod using NPM
You can install Zod using npm
:
npm i zod --save
Basic Usage
Zod allows us to validate any datatype you can think of, such as complex objects, promises, or "simple" values.
Thanks to the z
object imported from Zod
we can define the expected schema.
From the Zod readme:
const stringSchema = z.string();
stringSchema.parse("fish"); // => returns "fish"
stringSchema.parse(12); // throws Error('Non-string type: number');
If you need to validate more complex objects, we can use z.object()
.
This time around, though, you may want to simple return the validation
status instead of throwing an error. You can do so by calling safeParse
rather than parse
:
const schema = z.object({
displayName: z.string(),
email: z.string().email(),
});
const isValid = z.safeParse(payload); // boolean
This is useful if you want to return a custom error.
Using Zod with Next.js
We can access the client's request in a Next API using the `request parameter, which is the first argument of an API handler.
The req.body
object, which you receive in your API handler, is, for a good reason, typed with any
.
If you access the body
object without validating
your object, then you are blindly trusting the client to return what you'd
expect. As you may well know, this is rarely the case.
As you run Next.js in a serverless environment, any uncaught errors may cause your API instances to shut down, which also increases the amount of cold starts.
Slowing down your app is not ideal for you or your application's users.
By using Zod, we can safely access the data received or return the correct error back to the client.
Validating a Next.js API Endpoint
Let's assume our endpoint is expecting to receive the following interface:
interface Body {
userId: string;
newOrganization: {
name: string;
users: string[];
}
}
The above could easily be represented with the following Zod schema:
const schema = z.object({
userId: z.string().min(1),
newOrganization: z.object({
name: z.string().min(1),
users: z.array(
z.object({
email: z.string().email(),
})
).min(1),
})
});
Let's break this down. We expect our payload to receive:
- a string
userId
which cannot be emptymin(1)
- an object
newOrganization
containing:- a string
name
, which cannot be emptymin(1)
- an array
users
containing a list of objects:- with a property
email
accepting strings with a valid email format
- with a property
- a string
We will now run a few tests against some payloads to see how it behaves.
Parsing Objects with the Zod schema
It's essential to notice that Zod
returns a different payload when we
call it with .safeParse
.
-
if we validate payloads with
.parse
thenZod
will either resolve or throw an error, which you should make sure to catch in some way -
if we validate payloads with
.safeParse
Zod
returns the following interface:
{
data: T;
success: true;
} | {
success: false;
}
What does that mean for you?
If you plan on catching errors and have a good mechanism in place (for
example, using a middleware), then
you can use .parse
:
export default function apiHandler(
req: NextApiRequest,
res: NextApiResponse,
) {
try {
const data = schema.parse(req.body);
// we can safely use data with the shape provided
console.log(data.organizationId);
} catch(e) {
return res.status(400).send({
message: `Yo, bad payload!`
});
}
}
As you can see in the image above, the object returned by parse
is
correctly inferred by Typescript as
having the shape we validated. Neat and
powerful 馃敟
If you plan on using .safeParse
instead, we don't need to wrap our API
around a try/catch
block because it won't throw an error.
Instead, we need to check that the response contains success=true
which
correctly discriminates the union and means we can safely access our payload.
import { NextApiRequest, NextApiResponse } from "next";
export default function apiHandler(
req: NextApiRequest,
res: NextApiResponse,
) {
const response = schema.safeParse(req.body);
if (!response.success) {
return res.status(400).send({
message: `Yo, bad payload!`
});
}
console.log(response.data.organizationId) // works, and TS is happy
}
What does a Zod error look like?
When Zod
parses the payload and fails to validate it, it returns us the
reasons why the validation failed.
Let's assume our payload looks like the below. Clearly, it throws an
error because the array users
is empty, but we declared we want it to have
a minimum length of 1
:
const test = schema.safeParse({
userId: "123",
newOrganization: {
name: "My Organization",
users: []
}
});
console.log(`Test 1: expect an error`, JSON.stringify(test, null, 2));
This is the payload returned:
{
"success": false,
"error": {
"issues": [
{
"code": "too_small",
"minimum": 1,
"type": "array",
"inclusive": true,
"message": "Should have at least 1 items",
"path": [
"newOrganization",
"users"
]
}
],
"name": "ZodError"
}
}
It's not great to expose this data to your clients, so you may not want to return it straight as is.
Codesandbox Demo
You can play with Zod using the CodeSandbox snippet below. Please drag the sidebar on the left-hand side to see the code, and open the console to see the output, otherwise it shows you a blank screen.
Final Words
Zod
isn't the only library that can do this, but it has become my favorite one.
Joi
doesn't play as well with Typescript, class-validator
has always been
kind of weird with arrays and only works using classes with decorators
(which is not what you may be doing).
You can get started using it and read all the validations option it provides on Github.
I applied most of these concepts to my product Makerkit, a SaaS Starter for Next.js and Firebase. I write plenty of content over there too, check it out!