Skip to content

Fix: next-safe-action Not Working — Action Not Executing, Validation Errors Missing, or Type Errors

FixDevs ·

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 data

Or validation errors don’t propagate to the client:

const { execute, result } = useAction(myAction);
execute({ email: 'invalid' });
// result.validationErrors is undefined

Or 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 nothing

Why 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 firstcreateSafeActionClient() 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 checking validationErrors, you miss the feedback.
  • useAction result has a specific structureresult.data contains the return value, result.validationErrors contains schema errors, result.serverError contains thrown exceptions. Checking the wrong property makes it seem like nothing happened.
  • Middleware must call next() — middleware in the action chain must explicitly call next() 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 errors

Still 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles