Important: This documentation covers Yarn 1 (Classic).
For Yarn 2+ docs and migration guide, see yarnpkg.com.

Package detail

next-action-forge

Vorshim92278MIT0.5.0TypeScript support: included

A simple, type-safe toolkit for Next.js server actions with Zod validation

nextjs, server-actions, react, typescript, hooks, error-handling, form-handling, optimistic-updates, zod, validation

readme

Next Action Forge

A powerful, type-safe toolkit for Next.js server actions with Zod validation and class-based API design.

✨ Features

  • 🚀 Type-safe server actions with full TypeScript support
  • 🎯 Zod validation built-in with perfect type inference
  • 🏗️ Class-based API with intuitive method chaining
  • 🪝 React hooks for seamless client-side integration
  • 🔄 Optimistic updates support
  • 🔐 Middleware system with context propagation
  • Zero config - works out of the box
  • 🎨 Custom error handling with flexible error transformation
  • 🦆 Duck-typed errors - Any error with toServerActionError() method is automatically handled
  • 🚦 Server-driven redirects - Declarative redirect configuration with full hook support
  • 📋 Smart FormData parsing - Handles arrays, checkboxes, and files correctly
  • 🍞 Persistent toast messages - Toast notifications survive page redirects (v0.2.0+)
  • React 19 & Next.js 15 compatible

📦 Installation

npm install next-action-forge
# or
yarn add next-action-forge
# or
pnpm add next-action-forge

Requirements

  • Next.js 14.0.0 or higher
  • React 18.0.0 or higher
  • Zod 4.0.0 or higher

🚀 Quick Start

1. Create Server Actions

// app/actions/user.ts
"use server";

import { createActionClient } from "next-action-forge";
import { z } from "zod";

// Create a reusable client
const actionClient = createActionClient();

// Define input schema
const userSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
});

// Create an action with method chaining
export const createUser = actionClient
  .inputSchema(userSchema)
  .onError((error) => {
    console.error("Failed to create user:", error);
    return {
      code: "USER_CREATE_ERROR",
      message: "Failed to create user. Please try again.",
    };
  })
  .action(async ({ name, email }) => {
    // Your server logic here
    const user = await db.user.create({
      data: { name, email },
    });

    return user;
  });

// Action without input
export const getServerTime = actionClient
  .action(async () => {
    return { time: new Date().toISOString() };
  });

2. Use in Client Components

// app/users/create-user-form.tsx
"use client";

import { useServerAction } from "next-action-forge/hooks";
import { createUser } from "@/app/actions/user";
import { toast } from "sonner";

export function CreateUserForm() {
  const { execute, isLoading } = useServerAction(createUser, {
    onSuccess: (data) => {
      toast.success(`User ${data.name} created!`);
    },
    onError: (error) => {
      toast.error(error.message);
    },
  });

  const handleSubmit = async (formData: FormData) => {
    const name = formData.get("name") as string;
    const email = formData.get("email") as string;

    await execute({ name, email });
  };

  return (
    <form action={handleSubmit}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button disabled={isLoading}>
        {isLoading ? "Creating..." : "Create User"}
      </button>
    </form>
  );
}

🔧 Advanced Features

Middleware System

const authClient = createActionClient()
  .use(async ({ context, input }) => {
    const session = await getSession();
    if (!session) {
      return {
        error: {
          code: "UNAUTHORIZED",
          message: "You must be logged in",
        },
      };
    }

    return {
      context: {
        ...context,
        userId: session.userId,
        user: session.user,
      },
    };
  });

// All actions created from authClient will have authentication
export const updateProfile = authClient
  .inputSchema(profileSchema)
  .action(async (input, context) => {
    // context.userId is available here
    return db.user.update({
      where: { id: context.userId },
      data: input,
    });
  });

TypeScript Support in Middleware

Middleware automatically receives typed input when used after .inputSchema(). The order matters!

// ✅ CORRECT: inputSchema BEFORE use
export const createPostAction = actionClient
  .inputSchema(postSchema)
  .use(async ({ context, input }) => {
    // input is fully typed based on postSchema!
    console.log(input.title);  // TypeScript knows this exists

    // Example: Rate limiting
    const key = `rate-limit:${context.ip}:${input.authorId}`;
    if (await checkRateLimit(key)) {
      return { 
        error: { 
          code: "RATE_LIMITED", 
          message: "Too many requests" 
        } 
      };
    }

    return { context };
  })
  .action(async (input, context) => {
    // Your action logic
  });

// ❌ WRONG: use before inputSchema
export const wrongAction = actionClient
  .use(async ({ context, input }) => {
    // input is 'unknown' - no type safety!
    console.log(input.title);  // TypeScript error!
  })
  .inputSchema(postSchema)  // Too late for type inference!
  .action(async (input) => { /* ... */ });

Key Points:

  • Always define .inputSchema() before .use() for typed middleware input
  • Without a schema, input will be unknown in middleware
  • Middleware executes after input validation, so the data is already validated
  • Return { context } to continue or { error } to stop execution

Server-Driven Redirects

Define redirects that execute automatically after successful actions:

// Simple redirect
export const logoutAction = actionClient
  .redirect("/login")
  .action(async () => {
    await clearSession();
    return { message: "Logged out successfully" };
  });

// Redirect with configuration
export const deleteAccountAction = actionClient
  .redirect({ 
    url: "/goodbye", 
    replace: true,    // Use router.replace instead of push
    delay: 2000       // Delay redirect by 2 seconds
  })
  .action(async () => {
    await deleteUserAccount();
    return { deleted: true };
  });

// Conditional redirect based on result
export const updateProfileAction = actionClient
  .redirect((result) => result.needsVerification ? "/verify" : "/profile")
  .inputSchema(profileSchema)
  .action(async (input) => {
    const user = await updateUser(input);
    return { 
      user, 
      needsVerification: !user.emailVerified 
    };
  });

Client-side usage with hooks:

// With useServerAction
const { execute } = useServerAction(logoutAction, {
  onSuccess: (data) => {
    // This runs BEFORE the redirect
    toast.success("Logged out successfully");
  },
  preventRedirect: true,  // Optionally prevent automatic redirect
  redirectDelay: 1000,    // Global delay for all redirects
});

// With useFormAction (works the same!)
const { form, onSubmit } = useFormAction({
  action: loginAction, // Action with .redirect() configuration
  schema: LoginRequestSchema,
  onSuccess: (data) => {
    // This also runs BEFORE the redirect
    toast.success("Welcome back!");
  },
  preventRedirect: false, // Allow redirects (default)
  redirectDelay: 500,     // Override default delay
});

Form Actions

const contactSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  message: z.string().min(10),
});

export const submitContactForm = actionClient
  .inputSchema(contactSchema)
  .formAction(async ({ name, email, message }) => {
    // Automatically parses FormData
    await sendEmail({ to: email, subject: `Contact from ${name}`, body: message });
    return { success: true };
  });

// Use directly in form action
<form action={submitContactForm}>
  <input name="name" required />
  <input name="email" type="email" required />
  <textarea name="message" required />
  <button type="submit">Send</button>
</form>

React Hook Form Integration

"use client";

import { useFormAction } from "next-action-forge/hooks";
import { updateProfileAction } from "@/app/actions/profile";
import { z } from "zod";

const profileSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  bio: z.string().max(500).optional(),
  website: z.string().url("Invalid URL").optional().or(z.literal("")),
});

export function ProfileForm({ user }: { user: User }) {
  const { form, onSubmit, isSubmitting, actionState } = useFormAction({
    action: updateProfileAction, // Must be created with .formAction()
    schema: profileSchema,
    defaultValues: {
      name: user.name,
      bio: user.bio || "",
      website: user.website || "",
    },
    resetOnSuccess: false,
    showSuccessToast: "Profile updated successfully!",
    showErrorToast: true,
    onSuccess: (updatedUser) => {
      // Optionally redirect or update local state
      console.log("Profile updated:", updatedUser);
    },
  });

  return (
    <form onSubmit={onSubmit} className="space-y-4">
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          {...form.register("name")}
          className={form.formState.errors.name ? "error" : ""}
        />
        {form.formState.errors.name && (
          <p className="error-message">{form.formState.errors.name.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="bio">Bio</label>
        <textarea
          id="bio"
          {...form.register("bio")}
          rows={4}
          placeholder="Tell us about yourself..."
        />
        {form.formState.errors.bio && (
          <p className="error-message">{form.formState.errors.bio.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="website">Website</label>
        <input
          id="website"
          {...form.register("website")}
          type="url"
          placeholder="https://example.com"
        />
        {form.formState.errors.website && (
          <p className="error-message">{form.formState.errors.website.message}</p>
        )}
      </div>

      {/* Display global server errors */}
      {form.formState.errors.root && (
        <div className="alert alert-error">
          {form.formState.errors.root.message}
        </div>
      )}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Saving..." : "Save Profile"}
      </button>
    </form>
  );
}

// The server action must be created with .formAction()
// app/actions/profile.ts
export const updateProfileAction = actionClient
  .inputSchema(profileSchema)
  .formAction(async ({ name, bio, website }, context) => {
    const updatedUser = await db.user.update({
      where: { id: context.userId },
      data: { name, bio, website },
    });

    return updatedUser;
  });

Persistent Toast Messages (v0.2.0+)

Toast notifications now automatically persist across page redirects, perfect for authentication errors:

"use client";

import { useServerAction } from "next-action-forge/hooks";
import { ToastRestorer } from "next-action-forge/hooks";
import { deletePost } from "@/app/actions/posts";

// Add ToastRestorer to your root layout
export function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <ToastRestorer />
        {children}
      </body>
    </html>
  );
}

// Toast messages survive redirects automatically
export function DeleteButton({ postId }: { postId: string }) {
  const { execute, isExecuting, isRedirecting } = useServerAction(deletePost, {
    showErrorToast: true, // Error toasts persist through redirects
  });

  return (
    <button onClick={() => execute({ postId })} disabled={isExecuting || isRedirecting}>
      {isRedirecting ? "Redirecting..." : isExecuting ? "Deleting..." : "Delete Post"}
    </button>
  );
}

// Server action that triggers redirect on auth error
export const deletePost = authClient
  .inputSchema(z.object({ postId: z.string() }))
  .action(async ({ postId }, context) => {
    if (!context.user) {
      // This error message will show after redirect to login
      throw new Error("You must be logged in to delete posts");
    }

    await db.post.delete({ where: { id: postId } });
    return { success: true };
  });

Features:

  • Zero configuration - just add ToastRestorer to your layout
  • Compatible with future Sonner persistent toast feature
  • Automatically cleans up old toasts (30 seconds expiry)
  • Works with all redirect scenarios

Custom Error Classes

// Define your error class with toServerActionError method
class ValidationError extends Error {
  constructor(public field: string, message: string) {
    super(message);
  }

  toServerActionError() {
    return {
      code: "VALIDATION_ERROR",
      message: this.message,
      field: this.field,
    };
  }
}

// The error will be automatically transformed
export const updateUser = actionClient
  .inputSchema(userSchema)
  .action(async (input) => {
    if (await isEmailTaken(input.email)) {
      throw new ValidationError("email", "Email is already taken");
    }

    // ... rest of the logic
  });

Error Handler Adapter

// Create a custom error adapter for your error library
export function createErrorAdapter() {
  return (error: unknown): ServerActionError | undefined => {
    if (error instanceof MyCustomError) {
      return {
        code: error.code,
        message: error.message,
        statusCode: error.statusCode,
      };
    }

    // Return undefined to use default error handling
    return undefined;
  };
}

// Use it globally
const actionClient = createActionClient()
  .onError(createErrorAdapter());

Optimistic Updates

import { useOptimisticAction } from "next-action-forge/hooks";

function TodoList({ todos }: { todos: Todo[] }) {
  const { optimisticData, execute } = useOptimisticAction(
    todos,
    toggleTodo,
    {
      updateFn: (currentTodos, { id }) => {
        return currentTodos.map(todo =>
          todo.id === id ? { ...todo, done: !todo.done } : todo
        );
      },
    }
  );

  return (
    <ul>
      {optimisticData.map(todo => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.done}
            onChange={() => execute({ id: todo.id })}
          />
          {todo.title}
        </li>
      ))}
    </ul>
  );
}

📚 API Reference

ServerActionClient

The main class for creating type-safe server actions with method chaining.

const client = createActionClient();

// Available methods:
client
  .use(middleware)          // Add middleware
  .inputSchema(zodSchema)   // Set input validation schema
  .outputSchema(zodSchema)  // Set output validation schema
  .onError(handler)         // Set error handler
  .redirect(config)         // Set redirect on success
  .action(serverFunction)   // Define the server action
  .formAction(serverFunction) // Define a form action

// You can also create a pre-configured client with default error handling:
const clientWithErrorHandler = createActionClient()
  .onError((error) => {
    console.error("Action error:", error);
    return {
      code: "INTERNAL_ERROR",
      message: "Something went wrong",
    };
  });

// All actions created from this client will use the error handler
const myAction = clientWithErrorHandler
  .inputSchema(schema)
  .action(async (input) => {
    // Your logic here
  });

Hooks

  • useServerAction - Execute server actions with loading state and callbacks
    • Now includes isRedirecting state to track when redirects are in progress
  • useOptimisticAction - Optimistic UI updates
  • useFormAction - Integration with React Hook Form (works with .formAction() or form-compatible actions)
    • Now includes isRedirecting state to track when redirects are in progress

New in v0.3.2: Both useServerAction and useFormAction now return an isRedirecting boolean state that becomes true when a redirect is about to happen. This allows you to show appropriate UI feedback during the redirect transition:

const { form, onSubmit, isSubmitting, isRedirecting } = useFormAction({
  action: loginAction,
  // ...
});

// Show different states to the user
<button disabled={isSubmitting || isRedirecting}>
  {isRedirecting ? "Redirecting..." : isSubmitting ? "Logging in..." : "Login"}
</button>

Error Handling

The library follows a precedence order for error handling:

  1. Custom error handler (if provided via onError)
  2. Duck-typed errors (objects with toServerActionError() method)
  3. Zod validation errors (automatically formatted)
  4. Generic errors (with safe error messages in production)

📄 License

MIT

🤝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

🙏 Acknowledgments

Inspired by next-safe-action but with a simpler, more lightweight approach.