DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

next-safe-action: Type-Safe Server Actions in Next.js (2026)

Raw Next.js Server Actions have a gap. You define a function marked 'use server', call it from a form or a button click, and somewhere between the client and the server you're trusting that the data arrived with the right shape. No guaranteed validation, no type-safe context, no structured error responses — just a function call that could receive anything.

next-safe-action closes that gap. It wraps Server Actions with a schema layer (Zod or any Standard Schema compliant validator), a composable middleware chain, and React hooks that give you reactive status, typed results, and lifecycle callbacks. The v7 API made the middleware system composable in a way earlier versions weren't.

Installation

npm install next-safe-action zod
Enter fullscreen mode Exit fullscreen mode

Creating the Action Client

// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'

export const actionClient = createSafeActionClient({
  handleServerError(error) {
    console.error('Server action error:', error)
    if (error instanceof ActionError) return error.message
    return 'Something went wrong. Please try again.'
  },
})
Enter fullscreen mode Exit fullscreen mode

ActionError is next-safe-action's built-in class for surfacing user-facing errors:

import { ActionError } from 'next-safe-action'

throw new ActionError('This email is already registered.')
// → client sees this message in result.serverError
Enter fullscreen mode Exit fullscreen mode

Any other thrown error gets replaced by your handleServerError return — database errors and stack traces never leak to the client.

Authenticated Action Client

export const authActionClient = createSafeActionClient({
  handleServerError(error) {
    if (error instanceof ActionError) return error.message
    return 'Something went wrong.'
  },
}).use(async ({ next }) => {
  const session = await auth()

  if (!session?.user?.id) {
    throw new ActionError('You must be signed in.')
  }

  return next({
    ctx: {
      userId: session.user.id,
      user: session.user,
    },
  })
})
Enter fullscreen mode Exit fullscreen mode

Actions that use authActionClient get ctx.userId and ctx.user fully typed — no casting.

Defining Actions

// actions/todos.ts
'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { authActionClient } from '@/lib/safe-action'

const createTodoSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200),
  priority: z.enum(['low', 'medium', 'high']).default('medium'),
})

export const createTodo = authActionClient
  .schema(createTodoSchema)
  .action(async ({ parsedInput, ctx }) => {
    const [todo] = await db
      .insert(todos)
      .values({
        title: parsedInput.title,
        priority: parsedInput.priority,
        userId: ctx.userId,
      })
      .returning()

    revalidatePath('/todos')
    return { todo }
  })
Enter fullscreen mode Exit fullscreen mode

parsedInput is fully typed from your Zod schema. ctx is typed from your middleware chain.

The useAction Hook

'use client'

import { useAction } from 'next-safe-action/hooks'
import { createTodo } from '@/actions/todos'

function CreateTodoButton() {
  const { execute, result, isPending, reset } = useAction(createTodo, {
    onSuccess: ({ data }) => {
      toast.success(`"${data?.todo.title}" added`)
      reset()
    },
    onError: ({ error }) => {
      toast.error(error.serverError ?? 'Failed to create todo')
    },
  })

  return (
    <button
      onClick={() => execute({ title: 'New todo', priority: 'medium' })}
      disabled={isPending}
    >
      {isPending ? 'Adding...' : 'Add todo'}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode
Property Description
execute Triggers the action
executeAsync Same, but awaitable
result Last result: data, validationErrors, serverError
isPending True while running
status `'idle' \
{% raw %}reset Clears result state

Handling Validation Errors

Zod validation errors are separated from server errors and available field-by-field:

function CreateTodoForm() {
  const { execute, result, isPending } = useAction(createTodo)

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      const fd = new FormData(e.currentTarget)
      execute({ title: fd.get('title') as string, priority: 'medium' })
    }}>
      <input name="title" placeholder="Todo title" />
      {result.validationErrors?.title?._errors?.map((err) => (
        <p key={err} className="text-sm text-red-500">{err}</p>
      ))}

      {result.serverError && (
        <p className="text-sm text-red-500">{result.serverError}</p>
      )}

      <button type="submit" disabled={isPending}>Create</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Integration with React Hook Form

export function CreateTodoForm() {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { title: '', priority: 'medium' },
  })

  const { execute, isPending, result } = useAction(createTodo, {
    onSuccess: () => form.reset(),
  })

  // Merge server-side validation errors back into the form
  useEffect(() => {
    if (result.validationErrors) {
      Object.entries(result.validationErrors).forEach(([field, errors]) => {
        if (errors?._errors?.length) {
          form.setError(field as keyof FormValues, {
            message: errors._errors[0],
          })
        }
      })
    }
  }, [result.validationErrors, form])

  return (
    <form onSubmit={form.handleSubmit(execute)}>
      {/* form fields */}
      <Button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create'}
      </Button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Server-Side Validation Errors

For constraints only the DB can verify (email uniqueness, etc.):

import { returnValidationErrors } from 'next-safe-action'

export const createUser = authActionClient
  .schema(createUserSchema)
  .action(async ({ parsedInput }) => {
    const existing = await db.query.users.findFirst({
      where: eq(users.email, parsedInput.email),
    })

    if (existing) {
      return returnValidationErrors(createUserSchema, {
        email: { _errors: ['This email is already registered.'] },
      })
    }

    const user = await db.insert(users).values(parsedInput).returning()
    return { user: user[0] }
  })
Enter fullscreen mode Exit fullscreen mode

The client gets result.validationErrors.email._errors[0] — same shape as Zod errors.

Composable Middleware

// Rate-limited on top of auth
export const rateLimitedActionClient = authActionClient
  .use(async ({ next, ctx }) => {
    const { success } = await ratelimit.limit(ctx.userId)
    if (!success) throw new ActionError('Too many requests.')
    return next({ ctx })
  })

// Audit-logged for sensitive operations
export const auditedActionClient = authActionClient
  .use(async ({ next, ctx, clientInput }) => {
    const result = await next({ ctx })
    await db.insert(auditLogs).values({
      userId: ctx.userId,
      input: JSON.stringify(clientInput),
      timestamp: new Date(),
    })
    return result
  })
Enter fullscreen mode Exit fullscreen mode

useOptimisticAction

const { execute, optimisticState } = useOptimisticAction(toggleTodo, {
  currentState: todo,
  updateFn: (state, input) => ({
    ...state,
    completed: input.completed,
  }),
})

// optimisticState reflects the update immediately,
// reverts automatically if the server fails
Enter fullscreen mode Exit fullscreen mode

Quick Reference

// Setup
export const actionClient = createSafeActionClient({ handleServerError })
export const authActionClient = actionClient.use(async ({ next }) => {
  const session = await auth()
  if (!session) throw new ActionError('Unauthorized')
  return next({ ctx: { userId: session.user.id } })
})

// Define
'use server'
export const myAction = authActionClient
  .schema(z.object({ name: z.string() }))
  .action(async ({ parsedInput, ctx }) => {
    return { result: 'ok' }
  })

// Use
const { execute, result, isPending } = useAction(myAction, {
  onSuccess: ({ data }) => {},
  onError: ({ error }) => console.error(error.serverError),
})

// Errors
result.validationErrors?.fieldName?._errors  // Zod field errors
result.serverError                           // ActionError message
Enter fullscreen mode Exit fullscreen mode

Full article at stacknotice.com/blog/next-safe-action-nextjs-guide-2026

Top comments (0)