@sugardarius/anzen

Safe server action

API reference for the safe server action factory — validation, authorization, structured results.

createSafeServerAction builds a Next.js Server Action with optional Standard Schema input validation, optional authorization, and a consistent { success, output | error } result for callers. Validation-library agnostic: use Zod, Valibot, decoders, or any compatible schema.

Import

import { createSafeServerAction } from '@sugardarius/anzen'

Signature

The factory has two overloads: with an input schema (callers pass a plain object or FormData) and without (callers omit the argument).

// No input schema — optional call with no argument
function createSafeServerAction<TOutput, AC = undefined>(
  options: CreateSafeServerActionOptions<undefined, AC>,
  handler: (ctx: SafeServerActionContext<undefined, AC>) => Promise<TOutput>
): (
  providedInput?: undefined
) => Promise<SafeServerActionResult<TOutput, SafeServerActionError>>

// With input schema
function createSafeServerAction<
  TOutput,
  TInput extends StandardSchemaV1,
  AC = undefined,
>(
  options: CreateSafeServerActionOptions<TInput, AC> & { input: TInput },
  handler: (ctx: SafeServerActionContext<TInput, AC>) => Promise<TOutput>
): (
  providedInput: FormData | InferOutput<TInput>
) => Promise<SafeServerActionResult<TOutput, SafeServerActionError>>

Types exported from the package include CreateSafeServerActionOptions, SafeServerActionContext, SafeServerActionResult, and SafeServerActionError.

Options

When creating a safe server action you can use a bunch of options for helping you achieve different tasks 👇🏻

id

id?: string

Used for logging in development or when the debug option is enabled. You can also use it to add extra logging or monitoring. By default the id is set to [unknown:server:action].

actions/ping.ts
'use server'

import { createSafeServerAction } from '@sugardarius/anzen'

export const ping = createSafeServerAction(
  {
    id: 'monitoring/ping',
  },
  async ({ id }) => {
    return { pong: true, actionId: id }
  }
)

onError

onError?: OnError

Callback triggered when authorize throws (except Next.js control-flow errors; see Next.js control flow below) and when the action handler throws an error that is not a tagged error.

By default it returns a simple context (message / JSON) and the error is logged into the console.

Use it if you want to map known error types to a stable ctx for the client. By design, this callback is not meant for navigation (redirect, notFound) or for rethrowing — handle those in authorize, in the handler, or in the UI.

actions/save-document.ts
'use server'

import { createSafeServerAction } from '@sugardarius/anzen'
import { z } from 'zod'

class ConflictError extends Error {
  constructor(readonly docId: string) {
    super('version conflict')
    this.name = 'ConflictError'
  }
}

export const saveDocument = createSafeServerAction(
  {
    id: 'documents/save',
    input: z.object({ docId: z.string(), body: z.string() }),
    onError: async (err) => {
      if (err instanceof ConflictError) {
        return { message: 'Version conflict', docId: err.docId }
      }
      return { message: 'Unexpected error' }
    },
  },
  async ({ input }) => {
    await persist(input)
    return { saved: true }
  }
)

debug

debug?: boolean

Use this option to enable debug mode. It will add logs in the server action to help you trace validation, authorization, and execution.

By default it's set to false for production builds. In development builds, it will be true if NODE_ENV is not set to production.

actions/debug-demo.ts
'use server'

import { createSafeServerAction } from '@sugardarius/anzen'

export const debugDemo = createSafeServerAction({ debug: true }, async () => {
  return { ok: true }
})

authorize

authorize?: ServerActionAuthFunction<AC, TInput>

Function to use to authorize the server action. By default it always authorizes the action (no auth on the context).

It runs after input validation when input is defined, so you always see validated input in authorize and never run authorization on bad payloads.

Parameters:

  • id: string — Server action id (from id option or the default).
  • input — Present only when an input schema is configured. Type is the schema’s output.

Outcomes:

  • Return a value — That value becomes auth on the handler context (type-inferred).
  • Throw a normal error — The action resolves to { success: false, error: { code: 'UNAUTHORIZED_ERROR', ctx } } where ctx comes from onError (or the built-in fallback). Use this for “not logged in”, “forbidden”, invalid token, etc.
  • Throw redirect, notFound, or other Next.js special errors — The error is rethrown; Next.js handles it. The client does not receive a result object for that path.
Basic authorization - no input

Useful for actions that only need a session, or when the payload is not part of auth.

actions/list-notifications.ts
'use server'

import { createSafeServerAction } from '@sugardarius/anzen'
import { auth } from '~/lib/auth'

export const listNotifications = createSafeServerAction(
  {
    id: 'notifications/list',
    authorize: async ({ id }) => {
      const session = await auth()
      if (!session?.user) {
        throw new Error('Unauthenticated')
      }
      return { user: session.user }
    },
  },
  async ({ auth }) => {
    return { items: await fetchNotifications(auth.user.id) }
  }
)
Authorization with validated input

When the user must be allowed to act on a specific resource id (or similar), validate the payload first, then enforce access in authorize.

actions/delete-comment.ts
'use server'

import { z } from 'zod'
import { createSafeServerAction } from '@sugardarius/anzen'
import { auth } from '~/lib/auth'

export const deleteComment = createSafeServerAction(
  {
    id: 'comments/delete',
    input: z.object({ commentId: z.string().uuid() }),
    authorize: async ({ input }) => {
      const session = await auth()
      if (!session?.user) {
        throw new Error('Unauthenticated')
      }
      const allowed = await canDeleteComment(session.user.id, input.commentId)
      if (!allowed) {
        throw new Error('Forbidden')
      }
      return { user: session.user }
    },
  },
  async ({ input }) => {
    await db.comment.delete({ where: { id: input.commentId } })
    return { deleted: true }
  }
)

input

input?: TInput

Server action input schema: a Standard Schema value. When set, callers pass either a plain object matching the inferred type or FormData. For FormData, entries are converted with Object.fromEntries before validation, so field names must match your schema keys.

When omitted, the exported function accepts no argument (or undefined) and the handler context has no input.

actions/submit-note.ts
'use server'

import { z } from 'zod'
import { createSafeServerAction } from '@sugardarius/anzen'

export const submitNote = createSafeServerAction(
  {
    id: 'notes/submit',
    input: z.object({
      title: z.string().min(1),
      body: z.string().max(5000),
    }),
  },
  async ({ input }) => {
    await saveNote(input)
    return { id: crypto.randomUUID() }
  }
)
components/note-form.tsx
'use client'

import { submitNote } from '~/actions/submit-note'

export function NoteForm() {
  return (
    <form
      action={async (formData) => {
        const result = await submitNote(formData)
        // …
      }}
    >
      <input name='title' />
      <textarea name='body' />
      <button type='submit'>Save</button>
    </form>
  )
}

onInputValidationError

onInputValidationError?: OnInputValidationError

Callback triggered when input validation returns issues. By default issues are logged and ctx is { issues } (Standard Schema issue list).

Use it to return a friendlier or stable shape for the client (for example a single message plus optional issues).

actions/update-nickname.ts
'use server'

import { z } from 'zod'
import { createSafeServerAction } from '@sugardarius/anzen'

export const updateNickname = createSafeServerAction(
  {
    id: 'profile/nickname',
    input: z.object({ nickname: z.string().min(2).max(32) }),
    onInputValidationError: async (issues) => ({
      message: 'Invalid nickname',
      issues,
    }),
  },
  async ({ input }) => ({ nickname: input.nickname })
)

Handler context

SafeServerActionContext is the context object that is passed to the handler.

The handler receives a single context object:

FieldWhen
idAlways — resolved action id.
tagErr(code, ctx)Always — ends the action with a tagged error (see below).
authWhen authorize returned a value.
inputWhen an input schema was configured.

Return value

Every call resolves to a discriminated union:

  • { success: true, output } — Normal completion.
  • { success: false, error } — Structured failure.

Error handling

By design, the factory wraps the pipeline so most failures become a result object instead of throwing to the client. That keeps Client Components simple: one await, then branch on result.success.

error.codeMeaning
VALIDATION_ERRORThe payload did not satisfy input. No authorize or handler run. error.ctx is whatever onInputValidationError returns (default: { issues }).
UNAUTHORIZED_ERRORauthorize threw a non–Next.js error. error.ctx comes from onError. Use this for authentication and authorization failures you model as throws inside authorize.
SERVER_ERRORThe handler threw an error that is neither a tagged error nor a Next.js special error. error.ctx comes from onError. Use onError to map internal errors to safe, serializable fields for the UI.
Tagged (tagErr)Deliberate domain outcomes ("conflict", "already exists", etc.). error.code is the string you passed to tagErr; error.ctx is your object. They are not logged as unexpected server failures.
components/handle-result.tsx
'use client'

import { saveDocument } from '~/actions/save-document'

export function SaveButton(props: { docId: string; body: string }) {
  return (
    <button
      type='button'
      onClick={async () => {
        const result = await saveDocument({
          docId: props.docId,
          body: props.body,
        })
        if (result.success) {
          return
        }
        switch (result.error.code) {
          case 'VALIDATION_ERROR':
            console.warn(result.error.ctx)
            break
          case 'UNAUTHORIZED_ERROR':
            console.warn('Not allowed', result.error.ctx)
            break
          case 'SERVER_ERROR':
            console.error(result.error.ctx)
            break
          default:
            if (result.error.code === 'CONFLICT') {
              console.warn('Conflict', result.error.ctx)
            }
        }
      }}
    >
      Save
    </button>
  )
}

Next.js control flow

If redirect(), notFound(), or similar Next.js APIs run inside authorize or the handler, those errors are rethrown and do not produce { success: false, … }.

Synchronous Validation

Validation must be synchronous. The Standard Schema contract discourages async validation. If a schema resolves validation asynchronously, behavior is undefined and may throw at runtime.

Fair use note

Please note that if you are not using input validation, authorize, a shared { success, output | error } envelope, or tagErr, you may not need this factory. A plain async function with 'use server' is enough when you are happy to throw or return values directly.

createSafeServerAction is most valuable when you want Standard Schema input validation, optional auth context on the handler, consistent structured errors for Client Components, and tagErr for domain-specific failure codes.

actions/plain-vs-safe.ts
'use server'

import { createSafeServerAction } from '@sugardarius/anzen'

// Calling 👇🏻
export const safePing = createSafeServerAction({}, async () => ({
  at: new Date().toISOString(),
}))

// is equal to declare the server action this way 👇🏻
export async function plainPing() {
  return { at: new Date().toISOString() }
}

// excepts `createSafeServerAction` will provide by default a native error handling
// and will return a result (structured) object. That's the only advantage.

See also

Last updated on

On this page