Input validation
Input validation is the core feature of next-safe-action. Every action can define an input schema that validates and types the data before your server code runs. This uses the Standard Schema specification, so you can use any compatible validation library.
Defining an input schema
Use the .inputSchema() method to attach a schema to your action:
"use server";
import { z } from "zod";
import { actionClient } from "@/lib/safe-action";
export const createUser = actionClient
.inputSchema(z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(18).optional(),
}))
.action(async ({ parsedInput }) => {
// parsedInput is typed as { name: string; email: string; age?: number }
return { id: "123", ...parsedInput };
});"use server";
import * as v from "valibot";
import { actionClient } from "@/lib/safe-action";
export const createUser = actionClient
.inputSchema(v.object({
name: v.pipe(v.string(), v.minLength(2)),
email: v.pipe(v.string(), v.email()),
age: v.optional(v.pipe(v.number(), v.minValue(18))),
}))
.action(async ({ parsedInput }) => {
// parsedInput is typed as { name: string; email: string; age?: number }
return { id: "123", ...parsedInput };
});The parsedInput parameter in your server code is fully typed based on the schema's output type. TypeScript will error if you access a property that doesn't exist.
How validation works
When an action is called, the input goes through this flow:
- Client sends raw input: the unvalidated data from the client
- Standard Schema parse: the input is validated against your schema
- Success → your server code receives
parsedInput(typed and validated) - Failure → the action returns
validationErrorsimmediately (your server code never runs)
The raw clientInput is also available in middleware and server code if you need access to the original, unvalidated data.
After validation: validated middleware
If you need to run middleware that has access to the validated, typed parsedInput (for example, authorization checks that depend on the input), use useValidated() instead of use(). Validated middleware runs immediately after successful input validation and before your server code:
authClient
.inputSchema(z.object({ postId: z.string() }))
.useValidated(async ({ parsedInput, ctx, next }) => {
// parsedInput.postId is typed as string
const post = await db.post.findUnique({ where: { id: parsedInput.postId } });
if (!post || post.authorId !== ctx.user.id) throw new Error("Forbidden");
return next({ ctx: { post } });
})
.action(async ({ ctx }) => {
// ctx.post is typed and available
});If validation fails, validated middleware is completely skipped. See the middleware guide for more.
Validation error shapes
When validation fails, the error object structure depends on the configured shape. There are two built-in shapes:
Formatted (default)
The default shape mirrors your schema structure with _errors arrays at each level:
// Schema: z.object({ name: z.string().min(2), email: z.string().email() })
// Input: { name: "", email: "invalid" }
{
validationErrors: {
name: { _errors: ["String must contain at least 2 character(s)"] },
email: { _errors: ["Invalid email"] },
}
}Flattened
The flattened shape separates form-level errors from field-level errors:
// Same schema and input as above
{
validationErrors: {
formErrors: [],
fieldErrors: {
name: ["String must contain at least 2 character(s)"],
email: ["Invalid email"],
},
}
}You can set the default shape when creating the client:
const actionClient = createSafeActionClient({
defaultValidationErrorsShape: "flattened",
});Or override it per-action. See Custom Validation Errors for details.
Supported libraries
Any library that implements the Standard Schema specification works with next-safe-action. This includes:
See the Standard Schema integration guide for side-by-side comparisons.