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

v0.5.1

Published

A simple, type-safe toolkit for Next.js server actions with 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.