next-action-forge
Version:
A simple, type-safe toolkit for Next.js server actions with Zod validation
618 lines (516 loc) • 16.9 kB
Markdown
# 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
```bash
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
```typescript
// 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
```tsx
// 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
```typescript
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!**
```typescript
// ✅ 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:
```typescript
// 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:
```tsx
// 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
```typescript
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
```tsx
"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:
```tsx
"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
```typescript
// 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
```typescript
// 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
```tsx
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.
```typescript
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:
```typescript
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](https://github.com/TheEdoRan/next-safe-action) but with a simpler, more lightweight approach.