next-zod-action
v1.0.0
Published
Type-safe, builder-pattern interface for Next.js Server Actions powered by Zod
Maintainers
Readme
next-zod-action provides a type-safe, builder-pattern interface for Next.js Server Actions, powered by Zod and integrated seamlessly with react-hook-action.
Benefits
- Type-Safe: End-to-end type inference from server validation to client hooks.
- Input Validation: Automatic Zod validation for arguments with structured error returns.
- Middleware Support: Powerful chainable middleware for authentication, logging, and context injection.
- React Integration: Built-in
useActionhook that manages loading states and error mapping. - Standardized Errors: Replaces generic Next.js "Digest" errors with a predictable success/failure envelope.
- FormData Support: Automatically parses
FormDatawith support for nested objects (e.g.,user.name) and arrays (e.g.,tags[0]). - Secure by Default: Automatically sanitizes unexpected server errors to prevent information leakage.
- Metadata: Attach static metadata (e.g., permissions) to actions for smarter middleware guards.
📦 Installation
npm install next-zod-action zodpnpm
pnpm install next-zod-action zodyarn
yarn add next-zod-action zod📖 Usage
Initialize Client
Create a reusable action client, typically in src/lib/action.ts. You can configure how to handle unexpected server errors (e.g., logging to Sentry).
import { createActionClient } from 'next-zod-action/server';
export const action = createActionClient({
// Optional: runs only for unexpected crashes
handleServerError: (e) => {
console.error('Action Error:', e);
},
// Optional: custom generic message for the client
defaultServerError: 'Something went wrong. Please try again later.',
// Optional: automatically convert empty strings to undefined (default: true)
normalizeFormData: true,
});Middleware
Extend the client with middleware to inject context (like user sessions) or enforce guards. Use ActionError for messages you want the client to see.
// src/lib/action.ts
import { ActionError } from 'next-zod-action';
export const authAction = action.use(async ({ next, ctx }) => {
const session = await getSession();
if (!session) {
// This message is SAFE to show to the client
throw new ActionError('Unauthorized');
}
// Inject session into context for the next handler
return next({ ctx: { ...ctx, user: session.user } });
});Define Action
Create your server action using the builder pattern.
// src/app/actions.ts
'use server';
import { ActionError } from 'next-zod-action';
import { z } from 'zod';
import { authAction } from '@/lib/action';
const schema = z.object({
title: z.string().min(3),
priority: z.enum(['low', 'high']),
});
export const createTodo = authAction
.schema(schema)
.action(async ({ parsedInput, ctx }) => {
// If you throw a regular Error here, the client sees "Internal Server Error"
// If you throw an ActionError, the client sees your custom message.
if (await isSpam(parsedInput.title)) {
throw new ActionError('Spam detected');
}
const newTodo = await db.todo.create({
data: { ...parsedInput, userId: ctx.user.id },
});
return { id: newTodo.id, message: 'Created!' };
});Consume in React
Use the useAction hook to call the action with full type safety and automatic state management.
// src/components/TodoForm.tsx
'use client';
import { useAction } from 'next-zod-action/client';
import { createTodo } from '@/app/actions';
export function TodoForm() {
const { execute, result, validationErrors, isLoading } = useAction(
'create-todo',
createTodo
);
return (
<form action={() => execute({ title: 'Buy Milk', priority: 'high' })}>
<button disabled={isLoading}>
{isLoading ? 'Saving...' : 'Create Todo'}
</button>
{/* Structured Validation Errors */}
{validationErrors?.title && (
<p className='error'>{validationErrors.title[0]}</p>
)}
{/* Global Error (ActionError or sanitized generic error) */}
{result?.success === false && result.error && (
<p className='error'>{result.error}</p>
)}
{/* Success State */}
{result?.success && (
<p className='success'>Todo created: {result.data.message}</p>
)}
</form>
);
}FormData Support
You can pass FormData directly to your action (e.g., from a native <form action={createTodo}>). The library automatically parses it into a structured object, supporting nested keys and arrays:
<form action={createTodo}>
{/* user.name -> { user: { name: '...' } } */}
<input name='user.name' />
{/* tags[0] -> { tags: ['...'] } */}
<input name='tags[0]' />
<input name='tags[1]' />
<button type='submit'>Submit</button>
</form>Note: Since
FormDatavalues are always strings, usez.coercein your schema for non-string types (e.g.,z.coerce.number()).Empty Strings: By default, empty strings
""are converted toundefined. This allows Zod's.optional()to work as expected for optional form fields. You can disable this vianormalizeFormData: false.Action Binding: The library fully supports
.bind(e.g.,<form action={updateTodo.bind(null, id)}>). It automatically detectsFormDataregardless of its position in the arguments.
Metadata
Attach static metadata to actions for permission checks in middleware.
import { ActionError } from 'next-zod-action';
const adminAction = authAction
.metadata({ role: 'ADMIN' })
.use(async ({ next, metadata, ctx }) => {
if (metadata?.role && ctx.user.role !== metadata.role) {
throw new ActionError('Forbidden');
}
return next({ ctx });
});Error Handling & Security
Standardized error handling is a core feature of next-zod-action. It ensures that your client always receives a predictable response and that sensitive server-side information never leaks.
The ActionResult Type
Every action returns a standardized ActionResult object. This allows you to handle success and failure in a uniform way across your application.
type ActionResult<TInput, TOutput> =
| {
success: true;
data: TOutput;
}
| {
success: false;
error: string; // Human-readable message
validationErrors?: { [K in keyof TInput]?: string[] } & { [key: string]: string[] };
serverError?: boolean; // True if an exception was thrown
};Error Categorization
The library distinguishes between three scenarios to ensure security and a predictable developer experience:
- Validation Failures: Occur when the input fails the Zod schema. The
erroris always"Validation failed", andvalidationErrorscontains the field-specific issues. - Expected Errors (
ActionError): Throw this when you want to pass a specific message back to the client. This is ideal for business logic failures (e.g., "Insufficient permissions"). These are not logged as crashes. - Unexpected Errors: Any other error (e.g., database connection failure) is caught and sanitized. It returns a generic message (configurable via
defaultServerError) to prevent leaking sensitive system information. These are logged to the server console.
| Scenario | success | error | validationErrors | serverError | Logged? |
| :--------------------- | :-------- | :------------------------ | :----------------- | :------------ | :------ |
| Success | true | — | — | — | No |
| Validation Failed | false | "Validation failed" | { field: [...] } | — | No |
| ActionError thrown | false | err.message | — | true | No |
| Unexpected Crash | false | "Internal Server Error" | — | true | Yes |
📚 Documentation
For all configuration options, please see the API docs.
🤝 Contributing
Want to contribute? Awesome! To show your support is to star the project, or to raise issues on GitHub.
Thanks again for your support, it is much appreciated! 🙏
License
MIT © Shahrad Elahi and contributors.
