next-action-forge
v1.0.1
Published
Type-safe, secure-by-default toolkit for Next.js server actions with Zod validation
Maintainers
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,useOptimisticActionanduseFormAction(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/resolversQuick 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"unlessNODE_ENVis"development"exactly, and are logged through the client's logger in every environment. - A result that fails
.outputSchema()answersOUTPUT_VALIDATION_ERRORwithout field names, and aZodErrorthrown 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 aspasswordHashoraccessToken. - Mapping your errors safely. A custom
.onError()decides what the client sees. Never forwarderror.messageof errors you do not recognise: database errors (for example Prisma's) carry constraint names and code locations. Returnundefinedto 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 callsaction()with no argument. - The client receives
{ success: true, data, redirect? }or{ success: false, error }, whereerroris aServerActionError:
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 AuthContextErrors
A failed action is converted in this order:
- your
.onError(handler), if it returns a value; - an output validation failure:
OUTPUT_VALIDATION_ERROR(500), logged; - an input validation failure:
VALIDATION_ERRORwithfield,fieldsandformErrors; - any thrown value with a
toServerActionError()method (anErrorsubclass or a plain object); - 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, callredirect()fromnext/navigationin 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, anasynccallback or a named constant. Next.js 16 accepts both. RedirectConfigacceptsdelay(ms). The hooks'isRedirectingstaystrueuntil 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 }) => {});inputis the validated input, orundefinedwhen the failure happened before validation succeeded;rawInputis what the client sent. Log failed attempts fromrawInput.- Hooks receive redacted copies: keys matching
DEFAULT_REDACT_KEYS(passwords, tokens, secrets, API keys, cookies,*hash, card numbers…) hold"[REDACTED]". Configure withcreateActionClient({ redact: [...DEFAULT_REDACT_KEYS, "iban"] }), orredact: false. - Hook errors are logged with
logger.warnand never affect the action. Hooks registered on a base client apply to every client derived from it; builder methods returnthis, so a genericwithAuditHooks<C extends AnyServerActionClient>(client: C): Cneeds 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 withafter()fromnext/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()andunauthorized()also reach Next's boundaries.executeis stable across renders, andisExecutingstaystrueuntil every concurrent call settled.- A throwing
onSuccessis 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 onerrors.rootand 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")istrue. - Field names build structure:
address.cityandaddress[city]nest objects,items[0].namebuilds 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
