# 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](./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

```bash
npm install next-action-forge zod
# for useFormAction
npm install react-hook-form @hookform/resolvers
```

## Quick start

### 1. Create a client and an action

```ts
// 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 } };
});
```

```ts
// 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:

```tsx
// 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

```tsx
"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

```ts
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`:

```ts
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:

```ts
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.

```ts
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.

```ts
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

```ts
// 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

```ts
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`:

```ts
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`

```tsx
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.

```tsx
"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.

```ts
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
