Fix: next-safe-action Not Working — Action Not Executing, Validation Errors Missing, or Type Errors
Quick Answer
How to fix next-safe-action issues — action client setup, Zod schema validation, useAction and useOptimisticAction hooks, middleware, error handling, and authorization patterns.
The Problem
A safe action is defined but calling it does nothing:
const result = await myAction({ name: 'Alice' });
// result is undefined — no error, no dataOr validation errors don’t propagate to the client:
const { execute, result } = useAction(myAction);
execute({ email: 'invalid' });
// result.validationErrors is undefinedOr TypeScript complains about the action’s input type:
Argument of type '{ name: string }' is not assignable to parameter of type 'never'Or middleware runs but the action handler never executes:
// Middleware logs "authorized" but the action returns nothingWhy This Happens
next-safe-action wraps Next.js Server Actions with type-safe validation, error handling, and middleware. Common issues stem from:
- The action client must be created first —
createSafeActionClient()returns a builder. Actions are defined by chaining.schema()and.action(). Calling the raw function without the client setup means validation and middleware don’t run. - Schema validation gates execution — if the Zod schema rejects the input, the action handler never runs. Validation errors are returned in
result.validationErrors, not as thrown exceptions. If you’re not checkingvalidationErrors, you miss the feedback. useActionresult has a specific structure —result.datacontains the return value,result.validationErrorscontains schema errors,result.serverErrorcontains thrown exceptions. Checking the wrong property makes it seem like nothing happened.- Middleware must call
next()— middleware in the action chain must explicitly callnext()to pass control to the handler. Forgetting this causes the action to stall silently.
Fix 1: Create the Action Client
npm install next-safe-action zod// lib/safe-action.ts — create the client once
import { createSafeActionClient } from 'next-safe-action';
export const actionClient = createSafeActionClient({
// Global error handler — catches all thrown errors
handleServerError(error) {
console.error('Action error:', error);
// Return a user-friendly message instead of the raw error
if (error instanceof AuthError) {
return 'You must be logged in to perform this action.';
}
return 'Something went wrong. Please try again.';
},
});// actions/create-post.ts — define a safe action
'use server';
import { z } from 'zod';
import { actionClient } from '@/lib/safe-action';
const schema = z.object({
title: z.string().min(1, 'Title is required').max(200),
content: z.string().min(10, 'Content must be at least 10 characters'),
tags: z.array(z.string()).max(5, 'Max 5 tags'),
published: z.boolean().default(false),
});
export const createPost = actionClient
.schema(schema)
.action(async ({ parsedInput }) => {
// parsedInput is fully typed from the Zod schema
const post = await db.insert(posts).values({
title: parsedInput.title,
content: parsedInput.content,
tags: parsedInput.tags,
published: parsedInput.published,
}).returning();
// Return value becomes result.data on the client
return { post: post[0] };
});Fix 2: Use Actions in Client Components
// components/CreatePostForm.tsx
'use client';
import { useAction } from 'next-safe-action/hooks';
import { createPost } from '@/actions/create-post';
import { toast } from 'sonner';
function CreatePostForm() {
const { execute, result, status, isExecuting } = useAction(createPost, {
onSuccess: ({ data }) => {
toast.success(`Post "${data?.post.title}" created!`);
},
onError: ({ error }) => {
if (error.serverError) {
toast.error(error.serverError);
}
},
});
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
execute({
title: formData.get('title') as string,
content: formData.get('content') as string,
tags: (formData.get('tags') as string).split(',').map(t => t.trim()),
published: formData.get('published') === 'on',
});
}
return (
<form onSubmit={handleSubmit}>
<div>
<input name="title" placeholder="Title" />
{/* Display field-level validation errors */}
{result.validationErrors?.title?._errors && (
<p className="text-red-500 text-sm">
{result.validationErrors.title._errors[0]}
</p>
)}
</div>
<div>
<textarea name="content" placeholder="Content" />
{result.validationErrors?.content?._errors && (
<p className="text-red-500 text-sm">
{result.validationErrors.content._errors[0]}
</p>
)}
</div>
<div>
<input name="tags" placeholder="Tags (comma-separated)" />
</div>
<label>
<input type="checkbox" name="published" /> Publish immediately
</label>
{/* Server-level error */}
{result.serverError && (
<div className="bg-red-50 text-red-700 p-3 rounded">
{result.serverError}
</div>
)}
<button type="submit" disabled={isExecuting}>
{isExecuting ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}Fix 3: Middleware and Authorization
// lib/safe-action.ts — action client with middleware
import { createSafeActionClient } from 'next-safe-action';
import { auth } from '@/auth';
// Base client — no auth required
export const actionClient = createSafeActionClient({
handleServerError(error) {
return error instanceof Error ? error.message : 'Unknown error';
},
});
// Authenticated client — requires logged-in user
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await auth();
if (!session?.user) {
throw new Error('You must be logged in');
}
// Pass user context to the action handler
return next({ ctx: { user: session.user } });
});
// Admin-only client — extends authenticated client
export const adminActionClient = authActionClient.use(async ({ next, ctx }) => {
if (ctx.user.role !== 'admin') {
throw new Error('Admin access required');
}
return next({ ctx });
});// actions/delete-user.ts — admin-only action
'use server';
import { z } from 'zod';
import { adminActionClient } from '@/lib/safe-action';
export const deleteUser = adminActionClient
.schema(z.object({ userId: z.string() }))
.action(async ({ parsedInput, ctx }) => {
// ctx.user is available from middleware
console.log(`Admin ${ctx.user.id} deleting user ${parsedInput.userId}`);
await db.delete(users).where(eq(users.id, parsedInput.userId));
return { deleted: true };
});Fix 4: Optimistic Updates
'use client';
import { useOptimisticAction } from 'next-safe-action/hooks';
import { toggleTodo } from '@/actions/toggle-todo';
function TodoItem({ todo }: { todo: Todo }) {
const { execute, optimisticState } = useOptimisticAction(toggleTodo, {
currentState: todo,
updateFn: (state, input) => ({
...state,
completed: !state.completed,
}),
onError: () => {
toast.error('Failed to update todo');
},
});
return (
<li>
<input
type="checkbox"
checked={optimisticState.completed}
onChange={() => execute({ todoId: todo.id })}
/>
<span style={{
textDecoration: optimisticState.completed ? 'line-through' : 'none',
}}>
{todo.text}
</span>
</li>
);
}Fix 5: Bind Actions to Forms
// actions/update-profile.ts
'use server';
import { z } from 'zod';
import { authActionClient } from '@/lib/safe-action';
export const updateProfile = authActionClient
.schema(z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
}))
.action(async ({ parsedInput, ctx }) => {
await db.update(users)
.set(parsedInput)
.where(eq(users.id, ctx.user.id));
revalidatePath('/profile');
return { updated: true };
});// components/ProfileForm.tsx — with useAction
'use client';
import { useAction } from 'next-safe-action/hooks';
import { updateProfile } from '@/actions/update-profile';
function ProfileForm({ user }: { user: User }) {
const { execute, result, isExecuting } = useAction(updateProfile, {
onSuccess: () => toast.success('Profile updated'),
});
return (
<form onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
execute({
name: fd.get('name') as string,
bio: fd.get('bio') as string,
});
}}>
<input name="name" defaultValue={user.name} />
{result.validationErrors?.name?._errors?.[0] && (
<p className="text-red-500">{result.validationErrors.name._errors[0]}</p>
)}
<textarea name="bio" defaultValue={user.bio ?? ''} />
{result.serverError && (
<p className="text-red-500">{result.serverError}</p>
)}
<button disabled={isExecuting}>
{isExecuting ? 'Saving...' : 'Save'}
</button>
</form>
);
}Fix 6: Error Handling Patterns
// Structured error handling in actions
import { returnValidationErrors } from 'next-safe-action';
export const registerUser = actionClient
.schema(z.object({
email: z.string().email(),
password: z.string().min(8),
username: z.string().min(3).max(20),
}))
.action(async ({ parsedInput }) => {
// Check for existing user
const existing = await db.query.users.findFirst({
where: eq(users.email, parsedInput.email),
});
if (existing) {
// Return validation-style error for specific field
returnValidationErrors(schema, {
email: { _errors: ['This email is already registered'] },
});
}
const existingUsername = await db.query.users.findFirst({
where: eq(users.username, parsedInput.username),
});
if (existingUsername) {
returnValidationErrors(schema, {
username: { _errors: ['This username is taken'] },
});
}
// Create user
const user = await db.insert(users).values({
email: parsedInput.email,
username: parsedInput.username,
passwordHash: await hash(parsedInput.password),
}).returning();
return { user: user[0] };
});
// Client-side — errors appear in result.validationErrors
// just like Zod schema validation errorsStill Not Working?
Action returns undefined and nothing happens — the action function might not be exported with 'use server' at the top of the file. Without the directive, Next.js doesn’t treat it as a Server Action and the client can’t call it. Also verify the .action() handler returns a value — an empty handler returns undefined as result.data.
Validation errors are empty but input is invalid — check that the schema matches the input shape exactly. execute({ name: 'Alice' }) only works if the schema expects z.object({ name: z.string() }). Extra or missing fields cause unexpected behavior. Log parsedInput in the action handler to verify what arrives.
Middleware stops execution silently — every middleware must call return next(). If middleware doesn’t call next(), the chain stops and the action returns nothing. Also check that throw in middleware is caught by handleServerError — if the handler re-throws, the error might crash the server instead of returning to the client.
TypeScript error: “not assignable to parameter of type ‘never’” — the schema type isn’t being inferred. Make sure .schema(zodSchema) is called before .action(). If the schema is defined in a separate file, check that it’s properly imported and not undefined.
For related Next.js issues, see Fix: Next.js App Router Not Working and Fix: Conform Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Better Auth Not Working — Login Failing, Session Null, or OAuth Callback Error
How to fix Better Auth issues — server and client setup, email/password and OAuth providers, session management, middleware protection, database adapters, and plugin configuration.
Fix: Clerk Not Working — Auth Not Loading, Middleware Blocking, or User Data Missing
How to fix Clerk authentication issues — ClerkProvider setup, middleware configuration, useUser and useAuth hooks, server-side auth, webhook handling, and organization features.
Fix: nuqs Not Working — URL State Not Syncing, Type Errors, or Server Component Issues
How to fix nuqs URL search params state management — useQueryState and useQueryStates setup, parsers, server-side access, shallow routing, history mode, and Next.js App Router integration.
Fix: ts-rest Not Working — Contract Types Not Matching, Client Requests Failing, or Server Validation Errors
How to fix ts-rest issues — contract definition, type-safe client and server setup, Zod validation, Next.js App Router integration, error handling, and OpenAPI generation.