Tutorial
Building Type-Safe Server Actions in Next.js with Zod
Learn how to implement robust, type-safe server actions in Next.js using Zod for runtime validation and TypeScript type inference.
Server actions in Next.js 13+ provide a powerful way to handle server-side operations directly from your components. However, without proper type safety and validation, they can be prone to runtime errors. This tutorial will show you how to implement robust, type-safe server actions using Zod for both runtime validation and TypeScript type inference.
Prerequisites
Before starting, ensure you have:
- Next.js 14+ with the App Router
- TypeScript
- Node.js 18+ installed
1. Project Setup
Let’s start by creating a new Next.js project with TypeScript support.
npx create-next-app@latest typesafe-nextjs --typescript --tailwind --eslint --app
cd typesafe-nextjs
Install Zod for schema validation:
npm install zod
2. Creating the Validation Schema
First, we’ll set up a validation schema for a user registration form. This demonstrates how Zod provides both runtime validation and TypeScript types.
Create a new file at src/schemas/user.ts:
username: z.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be less than 20 characters"),
email: z.string()
.email("Invalid email address"),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[0-9]/, "Password must contain at least one number"),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"]
})
data?: T
error?: {
message: string
field?: string
}
}
3. Creating the Server Action
Now we’ll create a type-safe server action that uses our Zod schema for validation.
Create a new file at src/actions/user.ts:
'use server'
prevState: ActionResponse<{ userId: string }> | null,
formData: FormData
): Promise<ActionResponse<{ userId: string }>> {
try {
const rawData = Object.fromEntries(formData.entries())
const validatedData = UserSchema.parse(rawData)
const userId = 'user_' + Math.random().toString(36).slice(2)
return {
data: {
userId
}
}
} catch (error) {
if (error instanceof z.ZodError) {
const firstError = error.errors[0]
return {
error: {
message: firstError.message,
field: firstError.path.join('.')
}
}
}
console.error('Registration error:', error)
return {
error: {
message: 'An unexpected error occurred during registration'
}
}
}
}
4. Creating the Client Component
Let’s create a registration form that uses our type-safe server action.
Create a new file at src/components/RegistrationForm.tsx:
'use client'
const initialState: ActionResponse<{ userId: string }> = {}
const [state, formAction] = useFormState(registerUser, initialState)
const { pending } = useFormStatus()
return (
<form action={formAction} className="max-w-md mx-auto space-y-4 p-4">
<div>
<label htmlFor="username" className="block text-sm font-medium">
Username
</label>
<input
type="text"
id="username"
name="username"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
disabled={pending}
required
/>
{state.error?.field === 'username' && (
<p className="text-red-500 text-sm mt-1">{state.error.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
type="email"
id="email"
name="email"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
disabled={pending}
required
/>
{state.error?.field === 'email' && (
<p className="text-red-500 text-sm mt-1">{state.error.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
type="password"
id="password"
name="password"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
disabled={pending}
required
/>
{state.error?.field === 'password' && (
<p className="text-red-500 text-sm mt-1">{state.error.message}</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
disabled={pending}
required
/>
{state.error?.field === 'confirmPassword' && (
<p className="text-red-500 text-sm mt-1">{state.error.message}</p>
)}
</div>
{/* General error message */}
{state.error?.field === undefined && state.error?.message && (
<div className="text-red-500 text-sm">{state.error.message}</div>
)}
{/* Success message */}
{state.data && (
<div className="text-green-500 text-sm">Registration successful!</div>
)}
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 disabled:bg-blue-300"
disabled={pending}
>
{pending ? 'Registering...' : 'Register'}
</button>
</form>
)
}
}
5. Updating the Home Page
Finally, let’s update the home page to include our registration form.
Update src/app/page.tsx:
return (
<main className="min-h-screen p-8">
<h1 className="text-2xl font-bold text-center mb-8">
Type-Safe Registration Form
</h1>
<RegistrationForm />
</main>
)
}
Conclusion
You’ve now created a type-safe Next.js application with server actions validated by Zod. This setup provides several benefits:
- Runtime validation of all form data
- TypeScript type inference from Zod schemas
- Structured error handling and type-safe responses
- Clean separation of validation logic
- Improved developer experience with auto-completion and type checking
This pattern can be extended to handle more complex forms and data structures while maintaining type safety throughout your application. The combination of Next.js server actions and Zod validation provides a robust foundation for building type-safe full-stack applications.