Next.js/React with Zod - Ali Al Reza - Medium

콘텐츠

When fetching data from different servers, the data we receive may not always match the expected shape. This can lead to bugs in our applications. Therefore, it’s crucial to be extra vigilant when handling external data.

TypeScript alone is not sufficient for schema validation, so we need to incorporate dedicated schema validators like Zod or Yup.

In full-stack applications, both the front-end and back-end interact with multiple external data sources, including:

Front-end:

  • API requests from the back-end
  • Third-party API data
  • User input forms
  • Local storage
  • URL data

Back-end:

  • API requests from the front-end
  • Third-party API data
  • Environment variables
  • Webhooks
  • File system
  • Database
  • URLs

By implementing robust schema validation, we can ensure data integrity and prevent potential errors, resulting in more reliable and secure applications.

Picture for clear understanding how we get data in full stack application and where we need schema validator.

Why typescript is not enough?

I’m using React/Next.js to better understand why Zod is needed alongside TypeScript. Suppose we have a product/page.tsx page where we want to fetch data from /api/product.

product/page.tsx

"use client";

import React, { use, useEffect } from "react";

type Product = {
name: string;
price: number;
};

const Product = () => {
useEffect(() => {
fetch("/api/product")
.then((res) => res.json())
.then((data: Product) => {
console.log(data.name.toUpperCase());
});
}, []);

return <div>Product</div>;
};

export default Product;

/api/product

import { NextResponse } from "next/server";

export async function GET(request: Request) {
const product = {
name: "Product 1",
price: 100,
};

return NextResponse.json(product);
}

Suppose, there are separate backend and frontend team. they change the return type of the backend like this than what our app will be crashed. During runtime you don’t know exactly what the shape of data is going to be and because of that you may crash your application.

api/product/route.ts

import { NextResponse } from "next/server";

export async function GET(request: Request) {
const product = {
id: 1,
price: 100,
};

return NextResponse.json(product);
}

In this case, our app could crash. That’s why we need proper validation schema alongside TypeScript.

you may prevent application crashing by optional chaining.

product/page.tsx

"use client";

import React, { use, useEffect } from "react";

type Product = {
name: string;
price: number;
};

const Product = () => {
useEffect(() => {
fetch("/api/product")
.then((res) => res.json())
.then((product: Product) => {

    console.log(product?.name?.toUpperCase());  
  });  

}, []);

return <div>Product</div>;
};

export default Product;

But what if the product actually not exist and we want to show nice error message to the user. It doesn’t help us with and what if our return type exist but different type like price in string than optional chaining type doesn’t help at all.

api/product/route.ts

import { NextResponse } from "next/server";

export async function GET(request: Request) {
const product = {
id: 1,

price: "$100",  

};

return NextResponse.json(product);
}

product/page.tsx

"use client";

import React, { use, useEffect } from "react";

type Product = {
name: string;
price: number;
};

const Product = () => {
useEffect(() => {
fetch("/api/product")
.then((res) => res.json())
.then((product: Product) => {

    console.log(product?.name?.toUpperCase());
            });  

}, []);

return <div>Product</div>;
};

export default Product;

Then it will crash our app:

you can check the type of the number. like

product/page.tsx

"use client";

import React, { use, useEffect } from "react";

type Product = {
name: string;
price: number;
};

const Product = () => {
useEffect(() => {
fetch("/api/product")
.then((res) => res.json())
.then((product: Product) => {
console.log(product?.name?.toUpperCase());

    if (typeof product?.price === "number") {  
      console.log(product?.price?.toFixed(2));  
    }  
  });  

}, []);

return <div>Product</div>;
};

export default Product;

you can see this is very messy code and this is not robust solution.This is where schema validator comes into place and popular one is called Zod. There are other also like Yup and they all have a similar function which is to Simply validate the data.

Example 1–2: Zod for api request and third party api:

yarn add zod
or
npm i zod

Zod is runtime dependency and we can use during runtime.

product/page.tsx

"use client";

import React, { use, useEffect } from "react";
import { z } from "zod";

type Product = {
name: string;
price: number;
};

const productSchema = z.object({
name: z.string(),
price: z.number(),
});

const Product = () => {
useEffect(() => {
fetch("/api/product")
.then((res) => res.json())
.then((product: Product) => {

    const validatedProduct = productSchema.safeParse(product);
    if (!validatedProduct.success) {  
      console.error(validatedProduct.error.message);  
      return;  
    }
    console.log(validatedProduct.data);  
  });  

}, []);

return <div>Product</div>;
};

export default Product;

api/product/route.ts

import { NextResponse } from "next/server";

export async function GET(request: Request) {
const product = {
id: 1,
price: "$100",
};

return NextResponse.json(product);
}

price is not the right shape but our application doesn’t crash. Zod show nice error message in our console:

and it show which key the problem with here it the in the path key and value is name.

if we correct the return type we will see not error in the console.

api/product/route.ts

import { NextResponse } from "next/server";

export async function GET(request: Request) {
const product = {
name: "Product 1",
price: 100,
};

return NextResponse.json(product);
}

We can use it single source of truth. like what if we need a helper function where we need Product type? So, we can also use Zod.

product/page.tsx

"use client";

import React, { use, useEffect } from "react";
import { z } from "zod";

const productSchema = z.object({
name: z.string(),
price: z.number(),
});

type Product = z.infer<typeof productSchema>;

const getProductPrice = (product: Product) => {
return product.price;
};

const Product = () => {
useEffect(() => {
fetch("/api/product")
.then((res) => res.json())
.then((product: unknown) => {

    const validatedProduct = productSchema.safeParse(product);
    if (!validatedProduct.success) {  
      console.error(validatedProduct.error.message);  
      return;  
    }
    console.log(validatedProduct.data);  
  });  

}, []);

return <div>Product</div>;
};

export default Product;

If we change the Schema, Product type will be autometically be updated.

If we make an api request on our own backend there will be more robust solution which is called tRPC which does require little more setup. So, if i controll our own api routes we may want to use tRPC. which does something similar under the hood. If we doesn’t control the back-end we may use React Query with Zod before runing it on our application.

Exmaple 3: Form Data which is also comes from external source

We have three input field email,password and confirmPassword we can run it through react-hook-form / formic + zod . react-hook-form helps with:

⇒ form validation

⇒ error and loading states

⇒ performance and prevent unnecessary re-renders

where zod helps us with validate the schema. To add react-hook-form in our application:

yarn add react-hook-form
yarn add @hookform/resolvers

In perspective of Zod that why we need it because this form data will be needed in our backed. So we want to make single source of truth file that helps us with validate the data. When backend receives the data it also may validate the data bacuase we can not trust everything comes from the client. So it would be very nice if we validate the schema both on the client side as well as backend. But react-hook-form only do it from client side, we can not reuse it on the backend. However if we do from validation with Zod we can use that schema both client side and backend.

create a folder and file called: lib/types.ts this schema will be used both client side and backend.

import { z } from "zod";

export const signUpSchema = z
.object({

email: z.string().email(),  
password: z  
  .string()  
  .min(10, "password must be atleast 10 character")  
  .max(100),  
confirmPassword: z.string().min(10).max(100),  

})
.refine((data) => data.password === data.confirmPassword, {
message: "passwords do not match",
path: ["confirmPassword"],
});

export type TsignUpSchema = z.infer<typeof signUpSchema>;

create a page called: signup/page.tsx

import FormWithReactHookFormAndZod from "@/components/form-with-rhf-and-zod";
import React from "react";

const SignUp = () => {
return (
<div className="text-black">
<FormWithReactHookFormAndZod />
</div>
);
};

export default SignUp;

create a components folder copy the form layout into it:

components/form-with-rhf-and-zod.tsx

"use client";

import { TsignUpSchema, signUpSchema } from "@/lib/types";
import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

export default function FormWithReactHookFormAndZod() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
setError,
} = useForm<TsignUpSchema>({

resolver: zodResolver(signUpSchema),  

});

const onSubmit = async (data: TsignUpSchema) => {  
const response = await fetch("/api/signup", {  
  method: "POST",  
  body: JSON.stringify(data),  
  headers: {  
    "Content-Type": "application/json",  
  },  
});  
const responseData = await response.json();  
if (!response.ok) {  
  alert("Submitting form failed!");  
  return;  
}
if (responseData.errors) {  
  const errors = responseData.errors;
  if (errors.email) {  
    setError("email", {  
      type: "server",  
      message: errors.email,  
    });  
  } else if (errors.password) {  
    setError("password", {  
      type: "server",  
      message: errors.password,  
    });  
  } else if (errors.confirmPassword) {  
    setError("confirmPassword", {  
      type: "server",  
      message: errors.confirmPassword,  
    });  
  } else {  
    alert("Something went wrong!");  
  }  
}
reset();  

};

return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-y-2">
<input
{...register("email")}
type="email"
placeholder="Email"
className="px-4 py-2 rounded"
/>
{errors.email && (
<p className="text-red-500">{${errors.email.message}}</p>
)}

  <input  
    {...register("password")}  
    type="password"  
    placeholder="Password"  
    className="px-4 py-2 rounded"  
  />  
  {errors.password && (  
    <p className="text-red-500">{`${errors.password.message}`}</p>  
  )}
  <input  
    {...register("confirmPassword")}  
    type="password"  
    placeholder="Confirm password"  
    className="px-4 py-2 rounded"  
  />  
  {errors.confirmPassword && (  
    <p className="text-red-500">{`${errors.confirmPassword.message}`}</p>  
  )}
  <button  
    disabled={isSubmitting}  
    type="submit"  
    className="bg-blue-500 disabled:bg-gray-500 py-2 rounded"  
  >  
    Submit  
  </button>  
</form>  

);
}

In the backed create a route called:api/signup/route.ts

import { signUpSchema } from "@/lib/types";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
const body: unknown = await request.json();

const result = signUpSchema.safeParse(body);

if (!result.success) {
return {
status: 400,
body: result.error,
};
}

return NextResponse.json(result.data, {
status: 201,
});
}

We do the same validation with zod schema both client side and backend.

Example:4 Local Storage

We can fetch data from localStorage using the localStorage.getItem() method, but we might not know the exact type of the cart data. It could be an array, an object, or an array of objects. Therefore, after fetching the cart from localStorage, we use Zod to validate its shape against our expectations. If it doesn't match the expected shape, we remove the cart from localStorage, assuming it's outdated. If the cart does conform to the expected shape, we can confidently proceed knowing its type.

Create a cart/page.tsx file:

"use client";

import React from "react";
import { z } from "zod";

const cartSchema = z.array(
z.object({
id: z.number(),
quantity: z.number().int().positive(),
})
);

const Cart = () => {
if (typeof window !== "undefined") {

const cartData = localStorage.getItem("cart");
const cart: unknown = JSON.parse(cartData || "[]");
const validatedCart = cartSchema.safeParse(cart);
if (!validatedCart.success) {  
  localStorage.removeItem("cart");  
  return;  
}
console.log(validatedCart.data.map((item) => item.id));  

}

return <div>Cart</div>;
};

export default Cart;

Example: 5 Fetch Data From The Url

Sometimes we want to store data in the URL for various benefits. Once it’s in the URL, we need to read it into our app. In Next.js, we can use the useSearchParams() hook to access the searchParams object. However, as always, it's crucial to validate this data before working with it. Zod can be employed to ensure the searchParams conform to our expected structure.

Create a page called product-details/page.tsx

"use client";
import { useSearchParams } from "next/navigation";
import React from "react";
import { z } from "zod";

const searchParamsSchema = z.object({

id: z.coerce.number(),
color: z.enum(["red", "blue", "green"]),
});

const ProductDetails = () => {
const searchParams = useSearchParams();

const searchParamsObject = Object.fromEntries(searchParams);

const validatedSearchParams =
searchParamsSchema.safeParse(searchParamsObject);

if (!validatedSearchParams.success) {
return <div>Invalid search params</div>;
}

console.log(validatedSearchParams.data);

return <div>ProductDetails</div>;
};

export default ProductDetails;

we can type the params in the following way.

This five examples are the major source of external data on the client side.

For Backend:

Backends receive data from various sources, including their own API requests, third-party API requests, webhooks, environment variables, the file system, URLs, and databases. Zod can be employed to validate data in all of these scenarios.

Here’s a breakdown of how Zod can be integrated in the backend:

6. API Requests:

  • Validate incoming request data to ensure it adheres to expected structures.
  • Validate outgoing response data before sending it to clients.

7. Webhooks:

  • Another source for external data for a backend is web-hooks. If somebody completes a payment with stripe let’s say, stripe will send a message in the backend and they will include data in there. Before working with the data we can run it through zod to verify actually it cames from stripe.

8. Environment Variables:

  • Validate environment variables during application startup to catch configuration errors early.

9. File System:

  • Validate data read from files to ensure it’s in the anticipated format.

10. URLs:

  • Validate data fetched from URLs in the backend.

11. Database:

  • While ORMs like Prisma, Drizzle, or Mongoose Schema handle database schema validation, Zod can provide an extra layer of validation for data before it’s used in code.

Data fetching in Next.js backends can be performed using API Route handlers, Server Actions, or Server Components.

Example:6 Third-party API or Own Api request:

Backend get data from Third-party API or own api request through API Route Handlers. In the previous example 3 we create a single source of truth file called lib/types.ts We use this file for validate Client side as well as Backend. Here is also the same. we can use types.ts file in the backend to validate third-party api data or our own api request.

Example:8 Loading Env variables:

Loading the env variables lots of things can go wrong. So better approach create a env.ts file and validate the .env variable. Here you can create what enviroment variables to be.

env.ts

import { z } from "zod";

const envSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.number().int().positive(),
THIRD_PARTY_API_KEY: z.string().min(1),
});

export const parsedEnv = envSchema.parse(process.env);

When we use the env varibale we can use the parsedEnv like this. Supppose we want to use it api/product/route.ts file we can import the parsedEnv and accessed through the env file with auto complete.

api/product/route.ts

import { parsedEnv } from "@/env";
import { NextResponse } from "next/server";

export async function GET(request: Request) {
const product = {
name: "Product 1",
price: 100,
};

console.log(parsedEnv.DATABASE_URL);

return NextResponse.json(product);
}

Example: 9 Server Component URL

In nextjs server components get access to the search params. In the client side we get it through useSearchParams() but in here we get it through props and we can read data URL in a server component and nextjs does give us a type and than run through Zod to validate the param.

app/page.tsx

import React from "react";
import { z } from "zod";

const searchParamsSchema = z.object({
id: z.coerce.number(),
color: z.enum(["red", "blue", "green"]),
});

const Home = ({
searchParams,
}: {
searchParams: {
// it's gonna object with string as a key and string or string array as a value
[key: string]: string | string[] | undefined;
};
}) => {
// searchParamsSchema.safeParse(searchParams) will give me an object with id and color
const parsedSearchParams = searchParamsSchema.safeParse(searchParams);
console.log(searchParams);

// if the validation fails
if (!parsedSearchParams.success) {
return <div>Invalid search params</div>;
}

// if the validation succeeds
console.log(parsedSearchParams.data);

return <div>Home</div>;
};

export default Home;

now search the params like this: http://localhost:3000/?id=5&color=blue

In the console we access the id and color.

Example: 10 Database(ORM)

We can get data from database in route handlers, in server actions and in server components. If we use Prisma or Drizzle or Mongoose ORM, they are already validate the data that we get from our own database. If we use this orm we don’t need to recheck the validate schema.

The article was inspired by this YouTube channel

If you need all the code GitHub link is given below:

You can connect with me via LinkedIn.

요약하다
The article emphasizes the importance of schema validation when handling external data in full-stack applications to prevent bugs and ensure data integrity. It discusses the limitations of TypeScript in this regard and the need for dedicated schema validators like Zod or Yup. It illustrates scenarios where incorrect data shapes can lead to application crashes and how schema validators like Zod can help prevent such issues by providing clear error messages. The article also showcases examples of using Zod for API requests, third-party APIs, and form data validation, highlighting the benefits of having a single source of truth for data validation across the frontend and backend. Additionally, it mentions tools like tRPC and React Query for more robust solutions in handling API requests and data validation in applications.