npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

server-action-handler

v0.1.0

Published

Wrap Next.js Server Actions (or any async function) so every outcome comes back as a typed, serializable ActionResult — with automatic Zod validation handling and safe error logging.

Readme

server-action-handler

Wrap a Next.js Server Action (or any async function whose result crosses a serialization boundary) so it never throws — every outcome, success or failure, comes back as a typed ActionResult<TData> your client code can switch on.

const updateEmail = actionHandler('updateEmail', async (userId: string, email: string) => {
  const user = await db.user.update({ where: { id: userId }, data: { email } })
  return user
})
const result = await updateEmail(userId, email)
if (!result.success) {
  form.setError('email', { message: result.error.message })
  return
}
toast.success(result.message)

That's it. No try/catch in the caller, no guessing whether a given action returns null, throws, or resolves with { error }.

The problem

Server Actions run on the server but their return value is serialized straight to the client, which creates two problems every non-trivial app runs into:

'use server'

export async function updateEmail(userId: string, email: string) {
  const parsed = emailSchema.parse(email) // throws ZodError
  const user = await db.user.update({ where: { id: userId }, data: { email: parsed } })
  return user
}
  1. An uncaught throw becomes an opaque framework error on the client — no message, no status code, no field to attach to a form. You end up wrapping every action in the same try { ... } catch (error) { return { success: false, error: ... } } block, by hand, slightly differently each time.
  2. Every action invents its own response shape. One returns the row directly, another returns { data }, a third returns { error: string } on failure and the raw value on success. Client code ends up with a different unwrapping pattern per call site.

server-action-handler wraps the action once and standardizes both: thrown errors are caught and classified, and every outcome comes back as the same ActionResult<TData> union.

import { actionHandler } from 'server-action-handler'

export const updateEmail = actionHandler(
  'updateEmail',
  async (userId: string, email: string) => {
    const parsed = emailSchema.parse(email) // ZodError -> 422 validation_error, automatically
    return db.user.update({ where: { id: userId }, data: { email: parsed } })
  }
)
type ActionResult<TData> =
  | { success: true; data: TData; message: string }
  | { success: false; error: { message: string; statusCode: number; code: string; field?: string; meta?: unknown } }

Typed domain errors with CustomError

Throwing a plain Error still works (it becomes a generic 500 so internals never leak to the client), but for errors you want the client to actually handle — insufficient credit, a taken slug, a locked resource — extend CustomError:

import { CustomError, actionHandler } from 'server-action-handler'

class SlugTakenError extends CustomError {
  constructor(slug: string) {
    super(`"${slug}" is already in use.`, 409, { field: 'slug' })
  }
}

export const createPost = actionHandler('createPost', async (input: PostInput) => {
  if (await db.post.findUnique({ where: { slug: input.slug } })) {
    throw new SlugTakenError(input.slug)
  }
  return db.post.create({ data: input })
})
const result = await createPost(input)
if (!result.success) {
  // result.error.code === 'slug_taken' (inferred from the class name)
  // result.error.field === 'slug'
  // result.error.statusCode === 409
}

code is inferred from the class name (SlugTakenError → slug_taken) unless you pass one explicitly. cause is included in the serialized error only when NODE_ENV === 'development', so a wrapped DB error's stack trace never reaches production clients by default.

Zod, without a hard dependency

If the action throws a ZodError (e.g. from schema.parse(...)), it's automatically mapped to a 422 validation_error with meta.details as a { field: message[] } map — the shape most form libraries want for setError loops. This works by duck-typing { name: 'ZodError', issues }, so this package has zero required dependencies; you don't need zod installed for the rest of the library to work, and it doesn't pin a zod version alongside yours.

Using a different validator? Override the detector and formatter:

actionHandler('signup', action, {
  isValidationError: (error) => error instanceof MyValidationError,
  formatValidationError: (error) => ({ message: error.message, meta: { details: error.fields } }),
})

Logging

By default, unexpected errors are logged with console.error. Point it at your own logger/error tracker with onError, and redact what gets logged with sanitizeArgs (it already truncates long strings, summarizes Buffers, and collapses objects by default, so payloads and secrets don't end up verbatim in your logs):

actionHandler('updateEmail', action, {
  onError: ({ actionName, message, error, args }) => {
    logger.error({ actionName, args }, message, error)
  },
})

vs. next-safe-action

next-safe-action is the fuller-featured option if you want schema-validated input (client and server), middleware chains, and typed hooks — it's a framework of its own. server-action-handler does one thing: it standardizes the output of an action you've already written, with no client-side hook, no middleware system, and no required dependency on any validation library. Reach for this when you already have (or don't need) input validation and just want every action to return the same shape without a try/catch in every file.

Install

npm install server-action-handler

API

  • actionHandler(actionName, action, options?) — wraps an async function. action may return the data directly, or { data, message } to set a custom success message.
  • CustomError — base class for domain errors. new CustomError(message, statusCode, { field?, code?, meta?, cause?, exposeCause? }).
  • ActionResult<TData, TMeta>, ActionError<TMeta>, ActionSuccess<TData> — the response types.
  • isZodErrorLike, flattenIssues — the Zod duck-typing helpers, exported in case you want them outside actionHandler.

License

MIT