DH
4 min read

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.

nextjstypescriptzod

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:

import { z } from "zod"

export const UserSchema = z.object({
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"]
})

export type UserFormData = z.infer<typeof UserSchema>

export type ActionResponse<T> = {
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'

import { UserSchema, type ActionResponse } from "@/schemas/user"
import { z } from "zod"

export async function registerUser(
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'

import { useFormState, useFormStatus } from 'react-dom'
import { registerUser } from '@/actions/user'
import type { ActionResponse } from '@/schemas/user'

const initialState: ActionResponse<{ userId: string }> = {}

export default function RegistrationForm() {
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:

import RegistrationForm from '@/components/RegistrationForm'

export default function Home() {
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.

Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps