An empty ?limit= in the URL reaches Number(""), which returns 0. The list page renders nothing, and the cause is one missing character in a query string.
Query parameters, form fields, and Server Action arguments all arrive from outside and none of them can be trusted as they are. Zod validates those values and derives their TypeScript types from the same schema. This uses Zod 4 and covers the patterns I reach for most often.
Define a schema and its type together
Zod describes anything from a string to a nested object as a schema. parse returns a valid value and throws a ZodError on failure. At boundaries where an exception is inconvenient, safeParse returns a discriminated union instead.
import { z } from "zod";
export const User = z.object({
id: z.uuid(),
name: z.string().min(1).max(64),
bio: z.string().max(160).optional(),
});
export type User = z.infer<typeof User>;
const result = User.safeParse(input);
if (!result.success) {
console.error(result.error.issues);
return;
}
console.log(result.data.name);
TypeScript keeps values and types in separate namespaces, so both declarations can be called User. Naming them UserSchema and User is equally valid; choose the convention the team reads most easily.
Customize error messages
Zod 4 uses the error option for schema-level messages.
const DisplayName = z
.string({
error: (issue) =>
issue.input === undefined
? "Enter a display name"
: "The display name must be a string",
})
.min(2, { error: "Use at least two characters" });
For application-wide localization, configure one of Zod’s built-in locales with z.config(). Product-specific fields often still benefit from a message written for their context.
import { z } from "zod";
z.config(z.locales.en());
Be careful when logging validation failures. Zod does not include the input in issues by default, which helps avoid leaking sensitive values. Logging the original object alongside the error can defeat that protection.
Validate relationships between fields
Use refine for a rule that spans several fields, such as matching passwords.
export const PasswordForm = z
.object({
password: z.string().min(8),
confirmation: z.string(),
})
.refine((data) => data.password === data.confirmation, {
error: "Passwords do not match",
path: ["confirmation"],
});
Schemas containing regular expressions, refine, or transform are worth testing. Assert the parts your application depends on, such as the path and the user-facing message, rather than the entire error object.
it("rejects a different confirmation", () => {
const result = PasswordForm.safeParse({
password: "password",
confirmation: "different",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]).toMatchObject({
path: ["confirmation"],
message: "Passwords do not match",
});
}
});
Validate query parameters
Validate App Router searchParams or URLSearchParams at the page boundary. This is where the Number("") problem from the opening gets handled.
const optionalInteger = z.preprocess(
(value) => (value === "" || value == null ? undefined : value),
z.coerce.number().int().min(1).optional(),
);
const SearchQuery = z.object({
q: z.string().trim().optional(),
limit: optionalInteger,
page: optionalInteger,
});
const result = SearchQuery.safeParse({
q: searchParams.get("q") ?? undefined,
limit: searchParams.get("limit") ?? undefined,
page: searchParams.get("page") ?? undefined,
});
Using catch(undefined) for every invalid value makes malformed input indistinguishable from no input. Decide whether a field should fall back silently or show an error before choosing that behavior.
Combine Zod with React Hook Form
The zodResolver from @hookform/resolvers connects a Zod schema to the field errors in React Hook Form.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
const CreateUser = z.object({
username: z.string().min(3, { error: "Use at least three characters" }),
age: z.number().int().min(0).optional(),
agreement: z.literal(true, { error: "Agreement is required" }),
});
type CreateUserInput = z.input<typeof CreateUser>;
type CreateUserOutput = z.output<typeof CreateUser>;
export function UserForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateUserInput, unknown, CreateUserOutput>({
resolver: zodResolver(CreateUser),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<label htmlFor="username">Username</label>
<input id="username" {...register("username")} />
<p>{errors.username?.message}</p>
<label htmlFor="age">Age</label>
<input
id="age"
type="number"
{...register("age", {
setValueAs: (value) => (value === "" ? undefined : Number(value)),
})}
/>
<label>
<input type="checkbox" {...register("agreement")} />
I agree to the terms
</label>
<p>{errors.agreement?.message}</p>
<button type="submit">Save</button>
</form>
);
}
When a schema uses transform or coerce, its input and output types can differ. Pass z.input and z.output through useForm<Input, Context, Output> so the submitted value keeps the transformed type.
Empty numbers and dates
HTML form controls primarily produce strings. The valueAsNumber option in React Hook Form is useful, but an empty field becomes NaN. For an optional field, use setValueAs as above or normalize the value with preprocess in Zod.
Dates have the same issue. Before calling z.coerce.date(), decide how the form represents “not entered” and convert an empty string to undefined when appropriate.
File inputs
Extract the file you need from FileList, then validate it. If a schema also runs on the server, confirm that File and FileList exist in that runtime.
const UserIcon = z
.custom<FileList>((value) => value instanceof FileList)
.transform((files) => files.item(0))
.refine((file) => file === null || file.size <= 3_000_000, {
error: "Choose a file no larger than 3 MB",
})
.refine((file) => file === null || file.type === "image/png", {
error: "Choose a PNG image",
});
Client-side MIME type and size checks do not make an upload safe. Validate again on the server and handle the actual file format, name, and storage destination securely.
Validate Server Actions
Server Actions run on the server and are a good boundary before writing to a database or calling an external API. Client-side validation improves the interaction, but it never replaces server-side validation.
// actions.ts
"use server";
import { z } from "zod";
const CreateUser = z.object({
username: z
.string()
.trim()
.regex(/^[a-zA-Z0-9_]{6,10}$/, {
error: "Use 6–10 letters, numbers, or underscores",
}),
});
export type CreateUserState = {
errors?: Record<string, string[]>;
message?: string;
};
export async function createUser(
_previousState: CreateUserState,
formData: FormData,
): Promise<CreateUserState> {
const result = CreateUser.safeParse({
username: formData.get("username"),
});
if (!result.success) {
return {
errors: z.flattenError(result.error).fieldErrors,
};
}
await saveUser(result.data);
return { message: "Saved" };
}
In a Client Component, the React useActionState Hook exposes the returned state and submission status. The older useFormState API has been replaced by useActionState.
"use client";
import { useActionState } from "react";
import { createUser, type CreateUserState } from "./actions";
const initialState: CreateUserState = {};
export function Form() {
const [state, formAction, isPending] = useActionState(
createUser,
initialState,
);
return (
<form action={formAction}>
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
aria-describedby="username-error"
aria-invalid={Boolean(state.errors?.username)}
/>
<p id="username-error" aria-live="polite">
{state.errors?.username?.[0]}
</p>
<button type="submit" disabled={isPending}>
{isPending ? "Saving…" : "Save"}
</button>
</form>
);
}
If you use useFormStatus, call it from a component rendered inside the relevant <form>. A separate SubmitButton component is often the simplest arrangement.
Closing note
Adding Zod makes the places that accept untrusted values visible in the code, and only validated values move past them. The generated types are a byproduct.
Place schemas at boundaries such as URLs, forms, APIs, and persistence. Turn failures into messages people can act on, and distinguish input types from transformed output types. Data flow in a Next.js application gets much easier to follow.