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 beunknown
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
- Now includes
useOptimisticAction
- Optimistic UI updatesuseFormAction
- Integration with React Hook Form (works with.formAction()
or form-compatible actions)- Now includes
isRedirecting
state to track when redirects are in progress
- Now includes
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:
- Custom error handler (if provided via
onError
) - Duck-typed errors (objects with
toServerActionError()
method) - Zod validation errors (automatically formatted)
- 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.