Fast, flexible, framework validation agnostic, type‑safe factories for creating server actions, route handlers, page and layout Server Component files in Next.js.
import { object, string, datelike } from 'decoders'
import { createSafeServerAction } from '@sugardarius/anzen'
import { auth } from '~/lib/auth'
import { db } from '~/lib/db'
export const createThread = createSafeServerAction(
{
id: 'create-thread-action',
input: object({
spaceId: string,
createdAt: datelike,
comment: object({
createdAt: datelike,
content: string,
}),
}),
authorize: async ({ input }) => {
const session = await auth()
if (!session.user) {
throw new Error('user is not authenticated')
}
if (!session.access.includes(input.spaceId)) {
throw new Error('user has not access')
}
return { user: session.user }
},
},
async ({
auth, // Auth context is inferred from the authorize function
input, // Input is inferred from the input validation
}) => {
const inserted = await db.createThread({
thread: { ...input, authorId: auth.user.id },
})
return { inserted }
}
)npm i @sugardarius/anzen'use server'
import { object, string, datelike } from 'decoders'
import { createSafeServerAction } from '@sugardarius/anzen'
import { auth } from '~/lib/auth'
import { db } from '~/lib/db'
export const createThread = createSafeServerAction(
{
id: 'create-thread-action',
input: object({
spaceId: string,
createdAt: datelike,
comment: object({
createdAt: datelike,
content: string,
}),
}),
authorize: async ({ input }) => {
const session = await auth()
if (!session.user) {
throw new Error('user is not authenticated')
}
if (!session.access.includes(input.spaceId)) {
throw new Error('user has not access')
}
return { user: session.user }
},
},
async ({ auth, input, id }) => {
const inserted = await db.createThread({
thread: { ...input, authorId: auth.user.id },
})
return { inserted, actionId: id }
}
)Calling the action from a Client Component always yields a result object (never throws for validation, authorization failures, or ordinary handler errors):
'use client'
import { createThread } from '~/actions/create-thread'
export function CreateThreadForm() {
async function onSubmit(formData: FormData) {
const result = await createThread(formData)
if (result.success) {
console.log(result.output.inserted)
} else {
console.error(result.error.code, result.error.ctx)
}
}
return <form action={onSubmit}>…</form>
}The factory is validation-library agnostic. The input schema must implement the Standard Schema interface, so you can use Zod, Valibot, decoders, or any compatible library.
'use server'
import { z } from 'zod'
import { object, string } from 'decoders'
import { createSafeServerAction } from '@sugardarius/anzen'
// Zod
export const updateProfile = createSafeServerAction(
{
input: z.object({
displayName: z.string().min(1),
bio: z.string().max(500).optional(),
}),
},
async ({ input }) => ({ saved: input.displayName })
)
// decoders
export const renameSpace = createSafeServerAction(
{
input: object({
spaceId: string,
name: string,
}),
},
async ({ input }) => ({ id: input.spaceId, name: input.name })
)As with other Anzen factories, schemas should be synchronous. The Standard Schema contract discourages async validation. If a schema resolves validation asynchronously, behavior is undefined and may throw at runtime.
createSafeServerAction takes an options object and an action function. It returns a Next.js server action: a function you can pass to <form action={…}>, call from client code, or invoke on the server.
import {
type CreateSafeServerActionOptions,
type SafeServerActionContext,
createSafeServerAction,
} from '@sugardarius/anzen'
/** With input schema */
export const myAction = createSafeServerAction(
options: CreateSafeServerActionOptions<TInput, AC> & { input: TInput },
action: (ctx: SafeServerActionContext<TInput, AC>) => Promise<TOutput>
): (providedInput: FormData | InferOutput<TInput>) => Promise<SafeServerActionResult<TOutput, SafeServerActionError>>
/** Without input — omit `input`; the action may be called with no argument */
export const ping = createSafeServerAction(
options: CreateSafeServerActionOptions<undefined, AC>,
action: (ctx: SafeServerActionContext<undefined, AC>) => Promise<TOutput>
): (providedInput?: undefined) => Promise<SafeServerActionResult<TOutput, SafeServerActionError>>Every invocation resolves to a discriminated union:
success: true and output — the action completed normally.success: false and error — structured failure. error.code is one of:
VALIDATION_ERROR — input did not match the schema (ctx from onInputValidationError, default includes Standard Schema issues).UNAUTHORIZED_ERROR — authorize threw a non–Next.js error (ctx from onError).SERVER_ERROR — the action body threw an unexpected error (ctx from onError).tagErr in the handler.See Error handling for how each path behaves and how to customize payloads.
When creating a safe server action you can use a bunch of options for helping you achieve different tasks 👇🏻
id?: stringUsed 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'
import { createSafeServerAction } from '@sugardarius/anzen'
export const ping = createSafeServerAction(
{
id: 'monitoring/ping',
},
async ({ id }) => {
return { pong: true, actionId: id }
}
)onError?: OnErrorCallback triggered when authorize throws (except Next.js control-flow errors) 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'
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?: booleanUse 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'
import { createSafeServerAction } from '@sugardarius/anzen'
export const debugDemo = createSafeServerAction({ debug: true }, async () => {
return { ok: true }
})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:
auth on the handler context (type-inferred).{ 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.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.input)Useful for actions that only need a session, or when the payload is not part of auth.
'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) }
}
)inputWhen 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'
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?: TInputServer 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'
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() }
}
)'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?: OnInputValidationErrorCallback 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'
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 })
)The handler receives a single context object:
id — Resolved action id.tagErr(code, ctx) — Ends the action with { success: false, error: { code, ctx } } (see below).auth — Present when authorize returned a value; type matches that return type.input — Present when input was configured; type is the schema output.'use server'
import { createSafeServerAction } from '@sugardarius/anzen'
import { z } from 'zod'
export const example = createSafeServerAction(
{
id: 'example',
input: z.object({ q: z.string() }),
authorize: async () => ({ role: 'admin' as const }),
},
async ({ id, auth, input, tagErr }) => {
if (input.q === '') {
tagErr('EMPTY_QUERY', { message: 'Query required' })
}
return { id, role: auth.role, q: input.q }
}
)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.
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 errors (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'
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>
)
}tagErrUse tagErr for business rules you want to express in the handler without throwing, so the client always gets a typed error.code.
'use server'
import { z } from 'zod'
import { createSafeServerAction } from '@sugardarius/anzen'
export const reserveSlot = createSafeServerAction(
{
id: 'reserve-slot',
input: z.object({ slotId: z.string() }),
},
async ({ input, tagErr }) => {
const taken = await isSlotTaken(input.slotId)
if (taken) {
tagErr('SLOT_TAKEN', { slotId: input.slotId })
}
return { ok: true as const }
}
)const result = await reserveSlot({ slotId: 'a1' })
if (!result.success && result.error.code === 'SLOT_TAKEN') {
// result.error.ctx.slotId
}If the action calls redirect() or throws other Next.js special errors inside authorize or the handler body, those errors are rethrown. They do not produce { success: false, … }. Use that when navigation or framework errors should short-circuit the request the same way as in a normal Server Action.
Note: Next.js control-flow errors are not logged as failures; they are intentional framework behavior.
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'
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.Feel free to open an issue or a PR if you think a relevant option could be added into the factory 🙂
The factories requires Next.js v14, or v15, or v16 as peer dependency.
Thanks to @t3-oss/env-core for opening the implementation of StandardSchemaDictionary 🙏🏻