~Zod and React Hook Form

I’m learning Next.js; I know React or kinda know React since I started building React apps in 2018 or thereabout, and then stopped using it for several years. Shew! A lot has changed: Server actions, Zod, and just the whole ecosystem generally.
This will be a quick guide, purely meant as self-documentation to help me remember. I found it a bit annoying to find good documentation, it seems like the wild, wild west 🙃! many different documentation sources, and they all seem to do things differently.
PS: if you’re a Next.js pro, feel free to suggest a better way of doing things. I’m constantly looking to follow best practices wherever I can.
What are Server Components?
I personally think this is a game-changer, purely because I built many SPAs and understand the pain of maintaining an API, dealing with CORS, and context switching.
In Next.js, you can run React code on the server side. This means that Next.js will pre-compile the React code and then send the result back to the browser. So, for example, in the past, to fetch data in a component, you would need a separate API and boilerplate code like this:
"use client"
import { useEffect, useState } from "react";
interface Post {
id: number;
userId: number;
title: string;
body: string;
}
export default function Home() {
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
const loadPosts = async () => {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const apiPosts: Post[] = await response.json();
setPosts(apiPosts);
} catch (error) {
console.error('Failed to fetch posts:', error);
}
};
loadPosts();
}, []);
return (
<div>
{posts.map(post => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</div>
))}
</div>
);
}
It's such a pain. You have to use state and useEffect. Plus, you actually need an API. Here, I’m just using a public API, but for a real-world application, you’d need to build that API for your internal business data using Express.js or some other backend framework (You could also use Next.js API routes).
With Server Components We can greatly simplify this code; we don’t need to build API endpoints or set up new routes; we can just create a simple function and call it in our component:
interface Post {
id: number;
userId: number;
title: string;
body: string;
}
async function loadPosts() :Promise<Post[]> {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
return response.json();
}
export default async function Home() {
const posts = await loadPosts();
return (
<div>
{posts.map(post => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</div>
))}
</div>
);
}
You probably want to separate the API call into its own actions/xyz.ts file to keep things clean and this is an oversimplified example to demonstrate the concept, so it’s just got the bare minimum.
As you can see, this is a lot simpler, and since the template is pre-compiled with the data from our fetch call, by the time it reaches the browser, the data is already present, so there’s no need to use useEffect or state.
By default, all components are ServerComponents, If you need to use React hooks like useEffect, useState Or any JavaScript events, then you can force the component to behave just like a regular React component by adding ”use client” .
Zod - what?!
This is not a Pokémon or Transformer 😂 or (Zordon from Power Rangers). TypeScript is a superset of JavaScript; thus, types don’t work at runtime, since the browser cannot understand TypeScript. So when it comes to forms, you can’t actually use an interface to ensure the types are valid.
You still need validation at runtime to provide meaningful error messages and also ensure the data is in the correct format before saving to a database, hence the mighty zod :
import {z} from 'zod';
export const UserSchema = z.object({
name: z.string().min(3, {"error": "Please fill in a valid name."}),
email: z.email({ pattern: z.regexes.html5Email })
});
export type User = z.infer<typeof UserSchema>;
So basically, UserSchema It’s just an object with some validation rules. Now, since Zod returns a validation object, it can’t be used as a type throughout your code. For example, in my server action, I need to type the input form data so it’s usable in my code.
'use server'
import { User, UserSchema } from "@/lib/types"
export const saveUser = async (formData: User) => {
// other fun stuff here
}
To get an interface type out of Zod, simply do the following:
export type User = z.infer<typeof UserSchema>;
// then you can use fields like user.name, user.email etc...
Finally, you can use Zod to validate user input as follows in your server action.
export const saveUser = async (user: User) => {
const valid = UserSchema.safeParse(user);
if (!valid.success) {
console.log(valid.error);
return;
}
}
Dealing with forms using React Hook Form
Forms are one of the most common things in any app, but they’re also one of the most annoying things to deal with because you need to validate the form data, show meaningful error messages, preserve the form state, etc…
Luckily, there’s a powerful form library that just makes working with forms that much easier; it’s not part of Next.js by default, so you need to install it as follows:
pnpm install react-hook-form
Now, we can make a form to save a user (a very simplistic example). We use a client-side component in this instance because we want to show errors and prevent the form from clearing its fields. We do validation on the client first before even sending the form to the server, so it’s a much better user experience.
'use client';
import { saveUser } from "@/actions/forms";
import { UserSchema, User } from "@/lib/types";
import {zodResolver} from '@hookform/resolvers/zod';
import {useForm} from 'react-hook-form';
export default function Home() {
const form = useForm<User>({
resolver: zodResolver(UserSchema)
});
const onSubmit = async (data: User) => {
const result = await saveUser(data);
alert(result);
// result can anything you want to return from the server side.
};
return (
<div>
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register('name')}
type="text"
name="name"
className="bg-white border-2 mb-2 mt-2 text-slate-800"
/> <br />
<input
{...form.register('email')}
type="email"
name="email"
className="bg-white border-2 mb-2 mt-2 text-slate-800"
/>
<br />
{form.formState.errors.email && (
<p className="text-red-500">{form.formState.errors.email.message}</p>
)}
<br />
<input type="submit"
value="SEND"
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
/>
</form>
</div>
);
}
Breaking it down:
const form = useForm<User>({
resolver: zodResolver(UserSchema)
});
We create a strongly typed form and link it to Zod. This ensures each field is checked against the schema. You can bind each of the form elements using this function …form.register(‘name’) so that when Zod runs, it can access the elements’ values and other properties.
<input {...form.register('name')}
type="text"
name="name"
className="bg-white border-2 mb-2 mt-2 text-slate-800"
/>
Here’s where the magic happens:
onSubmit={form.handleSubmit(onSubmit)}
In this call, before the data is sent to the Server Action function to handle saving the user, our form object will validate the Zod schema first. If there are any errors, it will not trigger the onSubmit function and instead just fill the errors in our form state, which we can then access as follows:
<br />
{form.formState.errors.email && (
<p className="text-red-500">{form.formState.errors.email.message}</p>
)}
<br />
saveUser ~ is what we call a Server Action i.e., a function that components (both client and server) can call to perform some task on the server side.
In my project, I created a folder called actions which basically contains all my server actions.
Very Important: We need to ensure that the use server directive is always present in our server actions file, usually at the top of the file. This prevents the code from being bundled in the JavaScript code sent to the browser, which ensures a smaller JS file for the client to download. Furthermore, it prevents leaking any sensitive information to the browser, such as API keys and any other information you want to keep private.
"use server"
import { User, UserSchema } from "@/lib/types"
export const saveUser = async (user: User) :Promise<string> => {
const valid = UserSchema.safeParse(user);
if (!valid.success) {
return valid.error.flatten.toString();
}
// do some db stuff
return "Success";
}
If you came from the jQuery era like me, you’ll realize how much cleaner this is. We don’t need a 101 if statements to validate each form element, we can just use proper typing and let Zod do the rest.



