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
importfrom '@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].
'use server'
importfrom '@sugardarius/anzen'
export const ping = createSafeServerAction
'monitoring/ping',
asyncid=>
returntrue, 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.
'use server'
importfrom '@sugardarius/anzen'
importfrom 'zod'
class ConflictError extends Error
constructor(readonly docId: string) {
super'version conflict'
this.name = 'ConflictError'
}
export const saveDocument = createSafeServerAction
'documents/save',
objectstringstring
onError: asyncerr) =>
ifinstanceof ConflictError
return'Version conflict', docId: err.docId }
return'Unexpected error'
asyncinput=>
await persist
returntrue
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.
'use server'
importfrom '@sugardarius/anzen'
export const debugDemo = createSafeServerActiontrueasync=>
returntrue
})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 (fromidoption or the default).input— Present only when aninputschema is configured. Type is the schema’s output.
Outcomes:
- Return a value — That value becomes
authon the handler context (type-inferred). - Throw a normal error — The action resolves to
{ success: false, error: { code: 'UNAUTHORIZED_ERROR', ctx } }wherectxcomes fromonError(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.
'use server'
importfrom '@sugardarius/anzen'
importfrom '~/lib/auth'
export const listNotifications = createSafeServerAction
'notifications/list',
authorize: asyncid=>
const session = await auth
if!session?.user) {
throw new Error'Unauthenticated'
return
asyncauth=>
returnawait fetchNotifications
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.
'use server'
importfrom 'zod'
importfrom '@sugardarius/anzen'
importfrom '~/lib/auth'
export const deleteComment = createSafeServerAction
'comments/delete',
objectstringuuid
authorize: asyncinput=>
const session = await auth
if!session?.user) {
throw new Error'Unauthenticated'
const allowed = await canDeleteComment
if!allowed) {
throw new Error'Forbidden'
return
asyncinput=>
awaitdelete
returntrue
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.
'use server'
importfrom 'zod'
importfrom '@sugardarius/anzen'
export const submitNote = createSafeServerAction
'notes/submit',
object
stringmin1
stringmax5000
asyncinput=>
await saveNote
returnrandomUUID
'use client'
importfrom '~/actions/submit-note'
export function NoteForm() {
return
<form
={asyncformData) =>
const result = await submitNote
// …
>
<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).
'use server'
importfrom 'zod'
importfrom '@sugardarius/anzen'
export const updateNickname = createSafeServerAction
'profile/nickname',
objectstringmin2max32
onInputValidationError: asyncissues) =>
'Invalid nickname',
asyncinput=>
Handler context
SafeServerActionContext is the context object that is passed to the handler.
The handler receives a single context object:
| Field | When |
|---|---|
id | Always — resolved action id. |
tagErr(code, ctx) | Always — ends the action with a tagged error (see below). |
auth | When authorize returned a value. |
input | When 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.code | Meaning |
|---|---|
VALIDATION_ERROR | The payload did not satisfy input. No authorize or handler run. error.ctx is whatever onInputValidationError returns (default: { issues }). |
UNAUTHORIZED_ERROR | authorize 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_ERROR | The 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. |
'use client'
importfrom '~/actions/save-document'
export function SaveButton(props:docId: string; body: string
return
button
type='button'
onClick={async=>
const result = await saveDocument
if
return
switch
case 'VALIDATION_ERROR':
warn
break
case 'UNAUTHORIZED_ERROR':
warn'Not allowed', result.error.ctx)
break
case 'SERVER_ERROR':
error
break
default:
if=== 'CONFLICT'
warn'Conflict', result.error.ctx)
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.
'use server'
importfrom '@sugardarius/anzen'
// Calling 👇🏻
export const safePing = createSafeServerActionasync=>
new DatetoISOString
}))
// is equal to declare the server action this way 👇🏻
export async function plainPing() {
returnnew DatetoISOString
}
// 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