next-safe-action
Concepts

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:

src/app/actions.ts
"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 };
	});
src/app/actions.ts
"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:

  1. Client sends raw input — the unvalidated data from the client
  2. Standard Schema parse — the input is validated against your schema
  3. Success → your server code receives parsedInput (typed and validated)
  4. Failure → the action returns validationErrors immediately (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.

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:

LibraryStatus
ZodFully supported
ValibotFully supported
ArkTypeFully supported

See the Standard Schema integration guide for side-by-side comparisons.

What's next?

On this page