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

next-action-forge

v1.0.1

Published

Type-safe, secure-by-default toolkit for Next.js server actions with Zod validation

Readme

Next Action Forge

Type-safe, secure-by-default toolkit for Next.js Server Actions: Zod validation, typed middleware, lifecycle hooks and React hooks for the client.

  • Schema = input. An action with .inputSchema() validates what the client sends; an action without one takes no input.
  • Nothing leaks by default. Unexpected errors reach the client as a generic message (details only in development) and are logged on the server; output-schema failures never expose field names; redirect targets must be same-origin paths.
  • Typed from end to end. Middleware context, handler input, result and redirect callbacks are inferred. Zod and Zod Mini schemas both work.
  • Hooks for the client. useServerAction, useOptimisticAction and useFormAction (react-hook-form) with loading, redirect and toast handling.

Upgrading from 0.x? Read MIGRATION.md.

Requirements

| Dependency | Version | Notes | |---|---|---| | Next.js | 15 or 16, App Router | | | React | 19 | | | Zod | 4 | Peer dependency: install it in your app | | react-hook-form | 7.55 or later | Only for next-action-forge/hooks/form | | @hookform/resolvers | 5.1 or later | Only for next-action-forge/hooks/form (first version with Zod 4) | | TypeScript | 5.4 or later | If you use TypeScript |

Installation

npm install next-action-forge zod
# for useFormAction
npm install react-hook-form @hookform/resolvers

Quick start

1. Create a client and an action

// lib/action-client.ts
import "server-only";
import { createActionClient } from "next-action-forge";

export const actionClient = createActionClient();

export const authClient = actionClient.useBeforeValidation(async () => {
  const session = await getSession();
  if (!session) {
    return { error: { code: "UNAUTHORIZED", message: "Sign in first" } };
  }
  return { context: { userId: session.userId } };
});
// app/actions/profile.ts
"use server";

import { z } from "zod";
import { authClient } from "@/lib/action-client";

export const updateProfile = authClient
  .name("profile.update")
  .inputSchema(z.object({ name: z.string().min(2), bio: z.string().max(500) }))
  .action(async (input, context) => {
    // input is validated and typed; context.userId comes from the middleware
    return db.user.update({ where: { id: context.userId }, data: input });
  });

2. Configure toasts once

Toasts go through an adapter; sonner's toast satisfies it as-is. The provider takes a function, so render it from a Client Component:

// app/providers.tsx
"use client";

import { toast } from "sonner";
import { ActionForgeProvider } from "next-action-forge/hooks";

export function Providers({ children }: { children: React.ReactNode }) {
  return <ActionForgeProvider toast={toast}>{children}</ActionForgeProvider>;
}

3. Call the action from a Client Component

"use client";

import { useServerAction } from "next-action-forge/hooks";
import { updateProfile } from "@/app/actions/profile";

export function SaveButton({ name, bio }: { name: string; bio: string }) {
  const { execute, isExecuting } = useServerAction(updateProfile, {
    showSuccessToast: "Profile saved",
  });

  return (
    <button disabled={isExecuting} onClick={() => execute({ name, bio })}>
      {isExecuting ? "Saving…" : "Save"}
    </button>
  );
}

Security model

Server Actions are public HTTP endpoints: anyone can call them with any payload.

What the library guarantees

  • Input is validated before the handler runs; the handler never sees unvalidated data. An action without a schema ignores whatever the client sends.
  • Unexpected errors reach the client as "An unexpected error occurred" unless NODE_ENV is "development" exactly, and are logged through the client's logger in every environment.
  • A result that fails .outputSchema() answers OUTPUT_VALIDATION_ERROR without field names, and a ZodError thrown inside the handler is treated as an internal error. Only the input schema produces user-facing field errors.
  • Redirects returned by actions are followed only when they are same-origin paths starting with /.
  • Lifecycle hooks and .onError() receive redacted copies of inputs and outputs (passwords, tokens, secrets…).
  • The previous state passed to form actions is ignored; FormData decoding never pollutes prototypes.

What it does not do for you

  • Authorization. Check permissions in middleware or in the handler, for every action.
  • Choosing what to return. Without .outputSchema() everything the handler returns is sent to the client. In development the library warns once per action when a result contains keys such as passwordHash or accessToken.
  • Mapping your errors safely. A custom .onError() decides what the client sees. Never forward error.message of errors you do not recognise: database errors (for example Prisma's) carry constraint names and code locations. Return undefined to fall back to the generic message.
  • Rate limiting and CSRF protection beyond what Next.js provides.

Actions

actionClient
  .name("posts.create")                     // passed to lifecycle hooks
  .inputSchema(schema)                      // Zod or Zod Mini; async refinements work
  .outputSchema(publicPostSchema)           // strips and validates what the client receives
  .action(async (input, context) => post);  // with a schema
  • Without .inputSchema() the handler receives only the context: .action(async (context) => …), and the client calls action() with no argument.
  • The client receives { success: true, data, redirect? } or { success: false, error }, where error is a ServerActionError:
interface ServerActionError {
  message: string;
  code?: string;
  field?: string;                      // first failing field, dotted path ("address.city")
  fields?: Record<string, string[]>;   // messages per dotted path ("items.0.name")
  formErrors?: string[];               // errors of the whole input (cross-field refinements)
  statusCode?: number;
  shouldRedirect?: boolean;            // for authentication errors
  redirectTo?: string;
}

Calling an action from a Server Component is supported: Next.js control flow thrown inside it (redirect(), notFound(), dynamic-rendering bailouts) is rethrown, even when wrapped as another error's cause.

Middleware

After validation: .use()

Middleware registered with .use() runs after input validation, in registration order. It receives the context accumulated so far, typed, and the validated input (unknown when the schema is attached later), and returns either the context keys it adds or an error:

const client = createActionClient()
  .use(async () => ({ context: { requestId: crypto.randomUUID() } }))
  .inputSchema(z.object({ postId: z.string() }))
  .use(async ({ context, input }) => {
    const post = await db.post.find(input.postId);   // input is typed
    if (!post) return { error: { code: "NOT_FOUND", message: "Post not found" } };
    return { context: { post } };                    // merged with { requestId }
  });

Middleware that returns neither { context } nor { error: { message } } (for example undefined after a forgotten return) fails closed with a logged INTERNAL_ERROR.

Before validation: .useBeforeValidation()

Runs on the raw input before the schema, so authentication can reject an anonymous caller before validation errors reveal anything about the expected input. Register it before any .use() middleware; the builder throws otherwise.

Reusable middleware: createTypedMiddleware

Declare the types of middleware built by factories or shared between clients. The third type parameter is the context the middleware requires: .use() rejects a client that does not provide it.

interface AuthContext { userId: string; permissions: Set<string> }

const authMiddleware = createTypedMiddleware<AuthContext>(async () => {
  const session = await getSession();
  if (!session) return { error: { code: "UNAUTHORIZED", message: "Sign in first" } };
  return { context: { userId: session.userId, permissions: session.permissions } };
});

const requirePermission = (permission: string) =>
  createTypedMiddleware<{}, unknown, AuthContext>(async ({ context }) =>
    context.permissions.has(permission)          // context is typed
      ? { context: {} }
      : { error: { code: "FORBIDDEN", message: "Not allowed" } },
  );

export const adminClient = createActionClient()
  .useBeforeValidation(authMiddleware)
  .use(requirePermission("admin"));

createActionClient().use(requirePermission("admin")); // type error: no AuthContext

Errors

A failed action is converted in this order:

  1. your .onError(handler), if it returns a value;
  2. an output validation failure: OUTPUT_VALIDATION_ERROR (500), logged;
  3. an input validation failure: VALIDATION_ERROR with field, fields and formErrors;
  4. any thrown value with a toServerActionError() method (an Error subclass or a plain object);
  5. anything else: INTERNAL_ERROR (500), logged, generic message outside development.

.onError(handler) receives the error and { parsedInput, rawInput } (redacted). Use zodErrorToServerActionError(error) there to expose a ZodError thrown by the handler on purpose.

const client = createActionClient({
  logger: console,     // default; any { error, warn } object, or false to silence the library
  authRedirect: { codes: ["AUTHENTICATION_ERROR"], to: "/login" }, // default; false disables it
});

authRedirect adds shouldRedirect and redirectTo to errors converted through toServerActionError() whose code (or legacy type) matches; values set by the error itself win. The hooks then navigate there.

Redirects

// fixed target
actionClient.redirect("/dashboard").action(handler);

// computed from the result, typed from the handler (recommended)
actionClient.action(async () => ({ id: 42 }), {
  redirect: (result) => ({ url: `/posts/${result.id}`, replace: true }),
});
  • Targets must be same-origin paths starting with /. A fixed external target throws at definition time; a computed one is dropped and logged. To leave the app, call redirect() from next/navigation in the handler.
  • Callbacks may be async. After .outputSchema(), .redirect() types its callback's result as the schema output.
  • Next.js 15: in a "use server" file, a non-async function passed directly to .redirect() or .use() fails to compile ("Server Actions must be async functions"). Use the { redirect } option, an async callback or a named constant. Next.js 16 accepts both.
  • RedirectConfig accepts delay (ms). The hooks' isRedirecting stays true until the navigation settles.

Lifecycle hooks

actionClient
  .name("auth.login")
  .onStart(async ({ input, rawInput, context, name }) => {})
  .onSuccess(async ({ input, rawInput, context, output, duration }) => {})
  .onFailure(async ({ input, rawInput, context, error, duration }) => {})
  .onComplete(async ({ input, rawInput, context, result, duration }) => {});
  • input is the validated input, or undefined when the failure happened before validation succeeded; rawInput is what the client sent. Log failed attempts from rawInput.
  • Hooks receive redacted copies: keys matching DEFAULT_REDACT_KEYS (passwords, tokens, secrets, API keys, cookies, *hash, card numbers…) hold "[REDACTED]". Configure with createActionClient({ redact: [...DEFAULT_REDACT_KEYS, "iban"] }), or redact: false.
  • Hook errors are logged with logger.warn and never affect the action. Hooks registered on a base client apply to every client derived from it; builder methods return this, so a generic withAuditHooks<C extends AnyServerActionClient>(client: C): C needs no casts.
  • { fireAndForget: true } starts the hook after the action resolved without awaiting it. On serverless platforms the function may stop once the response is sent: for work that must complete, keep the hook awaited and schedule the slow part with after() from next/server.

Client hooks

useServerAction(action, options) — next-action-forge/hooks

Returns { execute, result, isExecuting, isRedirecting, hasSucceeded, hasErrored, reset }. execute(input) resolves to an ActionResult:

interface ActionResult<T> {
  status: "success" | "error" | "navigation";
  data?: T;
  serverError?: ServerActionError;
  validationErrors?: Record<string, string[]>;
  fetchError?: string;   // network failure
}

Options: onSuccess, onError, showSuccessToast (true for "Success!", a message or a function), successMessage, showErrorToast (default true), errorMessage, toast (overrides the provider), redirectOnAuthError (default true), preventRedirect, redirectDelay.

  • redirect() in the action resolves { status: "navigation" } without error toasts; notFound(), forbidden() and unauthorized() also reach Next's boundaries.
  • execute is stable across renders, and isExecuting stays true until every concurrent call settled.
  • A throwing onSuccess is logged and does not turn the success into an error.

useOptimisticAction(state, action, options) — next-action-forge/hooks

const { optimisticState, execute } = useOptimisticAction(todos, toggleTodo, {
  updateFn: (todos, { id }) => todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
});

The optimistic update and the call run in one transition, so optimisticState stays visible until the action settles; refresh todos (for example with revalidatePath) to show the result.

useFormAction(options) — next-action-forge/hooks/form

react-hook-form bound to a regular .action(): the form values are sent as an object, so dates, numbers, booleans and files keep their types.

"use client";

import { useFormAction } from "next-action-forge/hooks/form";
import { updateProfile } from "@/app/actions/profile";
import { profileSchema } from "@/lib/schemas";

export function ProfileForm() {
  const { form, onSubmit, isSubmitting, isRedirecting } = useFormAction({
    action: updateProfile,          // created with .action(), not .formAction()
    schema: profileSchema,          // optional client-side validation
    defaultValues: { name: "", bio: "" },
    resetOnSuccess: true,
  });

  return (
    <form onSubmit={onSubmit}>
      <input {...form.register("name")} />
      {form.formState.errors.name?.message}
      {form.formState.errors.root?.message}
      <button disabled={isSubmitting || isRedirecting}>Save</button>
    </form>
  );
}
  • Server field errors land on their dotted paths (address.city); form-level errors on errors.root and in a toast.
  • Returns { form, onSubmit, isSubmitting, isRedirecting, result, actionState, reset, handleSubmit }.
  • Other options: mode (default "onChange"), formOptions, transformData(values) => input, onSuccess, onError(error), showSuccessToast, showErrorToast, toast, preventRedirect, redirectDelay, redirectOnAuthError.

Native forms: .formAction()

For <form action> without JavaScript-driven state. React calls it with (formData), or with (prevState, formData) through useActionState; both work.

import { emptyToNull, emptyToUndefined } from "next-action-forge";

export const subscribe = actionClient
  .inputSchema(
    z.object({
      email: z.email(),
      newsletter: z.stringbool().default(false),                             // checkbox: "on" or missing
      age: z.preprocess(emptyToUndefined, z.coerce.number().int().optional()),
      nickname: z.preprocess(emptyToNull, z.string().nullable()),
      birthday: z.iso.date(),                                                 // <input type="date">
      tags: z.array(z.string()).default([]),                                  // <select multiple name="tags">
    }),
  )
  .formAction(async (input) => ({ ok: true }));
  • Values stay the strings the browser sent; declare conversions in the schema. Do not use z.coerce.boolean() for checkboxes: Boolean("false") is true.
  • Field names build structure: address.city and address[city] nest objects, items[0].name builds arrays, tags[] appends, repeated names become arrays, and a single value is wrapped when the schema expects an array.
  • The empty part of an unselected file input is dropped. decodeFormData(formData, schema?) exposes the same decoding.
  • Calling a form action without FormData answers INVALID_FORM_DATA (400).

Exports

  • next-action-forge (server-safe): createActionClient, ServerActionClient, createTypedMiddleware, handleServerActionError, zodErrorToServerActionError, isErrorResponse, OutputValidationError, InvalidFormDataError, isSafeRedirectPath, decodeFormData, emptyToNull, emptyToUndefined, DEFAULT_REDACT_KEYS, REDACTED, Next.js error guards (isRedirectError, isNotFoundError, isHTTPAccessFallbackError, isNextNavigationError) and all types.
  • next-action-forge/core: the same server-side API.
  • next-action-forge/hooks (client): useServerAction, useOptimisticAction, ActionForgeProvider.
  • next-action-forge/hooks/form (client): useFormAction.

License

MIT