| name | zod-v4 |
| description | Use this skill when validating untrusted inputs with Zod v4 (Server Actions, Route Handlers, forms), formatting errors, or migrating Zod v3 → v4. |
Zod v4 skill (Freelancerino)
Use this skill whenever you:
- validate inputs at module boundaries (Server Actions, Route Handlers, webhooks)
- validate shared “wire” payloads (forms, query params, search params, JSON)
- format errors for UI (field errors vs form errors)
- migrate Zod v3 patterns to Zod v4 APIs
This repo is tenant-scoped: never trust client-provided IDs. Always derive workspace_id server-side.
Quick rules (do these first)
- Validate at the boundary: accept
unknown (or minimally typed input), validate with Zod, then use parsed.data.
- Prefer
safeParse / safeParseAsync in server actions to avoid throwing across RSC boundaries.
- Return typed result unions, not raw thrown errors.
- Don’t leak sensitive input: avoid
reportInput: true in production unless you have a clear reason.
- Prefer Zod v4 error APIs:
- error customization:
error param (string or function)
- formatting:
z.treeifyError(), z.flattenError(), z.prettifyError()
Canonical Freelancerino pattern: Server Action validation
Use Zod to validate inputs, then enforce tenancy with requireWorkspaceCached().
- Accept
unknown where input crosses trust boundaries.
- Validate, then convert to DB shape.
Example:
"use server";
import * as z from "zod";
import { requireWorkspaceCached } from "@/lib/workspace";
import type { ActionResult } from "@/lib/actions/action-result";
import { AppError } from "@/lib/errors/codes";
const createClientSchema = z.object({
name: z.string().min(1, { error: "Name is required" }).max(255),
email: z.email().max(255).optional(),
});
export async function createClient(
input: unknown
): Promise<ActionResult<{ id: string }>> {
const { workspace } = await requireWorkspaceCached();
const parsed = createClientSchema.safeParse(input);
if (!parsed.success) {
return {
status: "error",
code: AppError.VALIDATION_FAILED,
message: "Invalid input",
messageKey: "Errors.validationFailed",
};
}
return { status: "ok", data: { id: "..." } };
}
UI-friendly error formatting
For “flat” forms, prefer z.flattenError():
const result = createClientSchema.safeParse(input);
if (!result.success) {
const flat = z.flattenError(result.error);
}
For nested structures (arrays/objects), prefer z.treeifyError():
const tree = z.treeifyError(result.error);
Zod v4 essentials (things you’ll use constantly)
Parse vs safeParse
schema.parse(value) throws ZodError.
schema.safeParse(value) returns { success: true, data } | { success: false, error }.
- Use
parseAsync/safeParseAsync if you use async refine or async transforms/codecs.
Input/output typing
Prefer:
z.infer<typeof schema> (output type)
z.input<typeof schema> / z.output<typeof schema> when input/output diverge (pipes, transforms, defaults, coercion)
Coercion (z.coerce.*) in v4
In Zod v4 the input type of coerced schemas is unknown by default. If you want a narrower input type, pass a generic:
const amount = z.coerce.number<number>();
Top-level string formats (v4 preferred)
Zod v4 promotes formats to top-level constructors (tree-shakable and concise). Prefer:
z.email() instead of z.string().email() (method form is deprecated)
z.uuid({ version: "v7" }) or z.uuidv7()
z.url(), z.httpUrl(), z.ipv4(), z.ipv6(), z.jwt(), etc.
Strict vs loose objects
Prefer explicit constructors:
z.strictObject({ ... }) (error on unknown keys)
z.looseObject({ ... }) (passthrough unknown keys)
z.object({ ... }) (default behavior strips unknown keys)
Zod 3 → Zod 4 gotchas (high impact)
1) message → error
Most APIs now standardize on the error param:
z.string().min(5, { error: "Too short" });
The old { message: "..." } still works but is deprecated.
2) invalid_type_error / required_error dropped
Use an error(issue) => ... function instead, commonly checking issue.input === undefined.
3) ZodError formatting helpers changed
ZodError.format() / ZodError.flatten() are deprecated
- use
z.treeifyError(err) / z.flattenError(err) instead
4) z.nativeEnum() deprecated
Use z.enum(MyEnumLike) (Zod v4 overload) instead.
5) z.record() behavior changed
- single-arg
z.record(schema) is not supported in v4
z.record(keySchema, valueSchema) required
- if the key schema is an enum or literal union, Zod v4 checks exhaustiveness
- use
z.partialRecord(...) to allow missing keys
6) .default() vs .prefault()
.default() now short-circuits parsing on undefined, so the default value must match the schema’s output.
- use
.prefault() when you want “default before parsing” behavior (old v3 style).
7) z.promise() deprecated
If something might be a promise: await it first, then parse.
8) z.function() changed
z.function() is now a function factory (not a schema). Define input and output up-front; use .implementAsync() for async.
“New in v4” APIs worth using
z.stringbool() for env-style booleans
z.templateLiteral([...]) for template literal types (e.g. ${number}px)
- Codecs (
z.codec, z.encode, z.decode) for bidirectional transforms (great at network boundaries)
- JSON Schema conversion:
z.toJSONSchema(schema, opts); note unrepresentable types and io: "input" vs output mode
Error customization (v4)
Per-schema
z.string({ error: "Not a string" });
Conditional message
z.string({
error: (issue) => (issue.input === undefined ? "Required" : "Invalid input"),
});
Global configuration
z.config({
customError: (issue) => {
return "Invalid input";
},
});
Locales (i18n)
Zod v4 supports locales via z.config(z.locales.<lang>()).
In Freelancerino, keep in mind we already have next-intl; decide whether Zod messages should be localized server-side or mapped to translation keys.
Guidance specific to this repo
- Tenancy: validate IDs (UUID), but still enforce
workspace_id in every query/mutation.
- Prefer boundary schemas: keep Zod schemas close to the server action/handler entry.
- Avoid client-side heavy validation: do UX-only hints on the client; keep authoritative validation on the server.
Current codebase note
Some files currently use Zod v3-style APIs (e.g. { message: ... } and z.string().email(...)). When touching those files, migrate to v4 style:
{ message: "..." } → { error: "..." }
z.string().email() → z.email()
err.flatten() / err.format() → z.flattenError(err) / z.treeifyError(err)
Testing / validation checklist
- Add tests for:
- invalid inputs return stable errors (no throws across RSC)
- tenant isolation is enforced (
workspace_id always applied)
- edge cases (empty strings, optional fields, defaults/prefaults)
- Prefer
safeParse in tests so you can assert on issues.
Cost & performance notes
- Validate once at the boundary; avoid re-parsing the same payload multiple times through the call stack.
- Prefer returning compact, structured errors (field/form) rather than dumping large error payloads into logs.
- Avoid "debug" logging of inputs in production—besides security risk, it increases log volume and can become a cost/latency footgun.
Reference: .github/skills/nextjs-cost-performance/SKILL.md
References
Zod docs (v4):
Freelancerino anchors:
- Existing validation examples:
app/actions/clients/mutations.ts
lib/validation/zod-action-errors.ts
- Tenancy helper:
lib/workspace.ts
- DB schema:
db/schema.ts