UNPKG

next-action-forge

Version:

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

618 lines (516 loc) 16.9 kB
# 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.