
Next.js server actions provide a powerful way to handle form submissions and data mutations directly from your components. However, implementing proper validation and authentication checks in every server action can lead to repetitive code. In this article, we'll explore an elegant middleware pattern that separates validation and authentication concerns from your business logic, resulting in cleaner, more maintainable code.
The Problem with Traditional Server Actions
When working with Next.js server actions, you'll often find yourself repeating two common tasks across multiple actions:
- Validating incoming data from forms
- Checking if a user is authenticated before allowing the action to proceed
Implementing these checks in each server action leads to duplicated code, making your codebase harder to maintain and more prone to errors. The DRY (Don't Repeat Yourself) principle suggests we should extract this common functionality into reusable utilities.
Introducing the Server Action Middleware Pattern
The middleware pattern we'll implement provides two main utilities:
- validatedAction: A wrapper that validates incoming data against a schema
- validatedActionWithUser: A wrapper that both validates data and ensures the user is authenticated
This approach is inspired by middleware patterns in other frameworks but is specifically tailored for Next.js server actions. It's important to note that this is different from Next.js middleware - we're creating functional wrappers for our server actions.
Setting Up the Foundation: Types and Utilities
Let's start by creating a new file called 'action-helpers.ts' (avoiding the name 'middleware.ts' to prevent confusion with Next.js middleware). First, we'll define some TypeScript types to ensure type safety throughout our implementation.
// action-helpers.ts
import { z } from 'zod';
// Define a flexible type for action results
type ActionState = {
error?: string;
success?: string;
[key: string]: any; // Allow for custom data
};
// Type for our validated action function
type ValidatedActionFunction<S extends z.ZodSchema, T> = (
data: z.infer<S>,
formData: FormData
) => Promise<T>;
The ActionState type provides a standardized structure for server action responses, with optional error and success fields, plus the ability to include any custom data. The ValidatedActionFunction type describes the shape of the business logic function that will be passed to our middleware.
Implementing the validatedAction Middleware
Now let's implement our first middleware function that handles data validation using Zod:
// Middleware for validating data with a schema
export function validatedAction<S extends z.ZodSchema, T>(
schema: S,
action: ValidatedActionFunction<S, T>
) {
return async (formData: FormData) => {
// Convert FormData to a plain object
const data = Object.fromEntries(formData);
// Validate with Zod
const result = schema.safeParse(data);
// If validation fails, return the error
if (!result.success) {
return { error: result.error.issues[0].message } as T;
}
// If validation succeeds, run the action with validated data
return action(result.data, formData);
};
}

This middleware function takes a Zod schema and an action function as arguments. It returns a new server action that first validates the incoming form data against the schema. If validation fails, it returns an error message; if it succeeds, it calls the original action with the validated data.
Adding User Authentication with validatedActionWithUser
Next, let's extend our pattern to include user authentication checks:
// Middleware for validating data and ensuring user is authenticated
export function validatedActionWithUser<S extends z.ZodSchema, T>(
schema: S,
action: ValidatedActionFunction<S, T>
) {
return async (formData: FormData) => {
// Check if user is authenticated
const session = await getSession(); // Your auth library function
if (!session?.user) {
return { error: "You must be logged in" } as T;
}
// Convert FormData to a plain object
const data = Object.fromEntries(formData);
// Validate with Zod
const result = schema.safeParse(data);
if (!result.success) {
return { error: result.error.issues[0].message } as T;
}
// If validation succeeds and user is authenticated, run the action
return action(result.data, formData);
};
}

This middleware works similarly to validatedAction but adds an authentication check before proceeding with validation. If the user isn't logged in, it returns an error message without even attempting to validate the data.
Using the Middleware in Your Server Actions
Now let's see how to use these middleware functions in your actual server actions:
// actions.ts
import { z } from 'zod';
import { validatedAction, validatedActionWithUser } from './action-helpers';
// Define your validation schemas
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8, "Password must be at least 8 characters")
});
const signupSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email(),
password: z.string().min(8, "Password must be at least 8 characters")
});
// Server action with validation
export const loginEmail = validatedAction(loginSchema, async (data) => {
// Here, data is already validated and typed as { email: string, password: string }
// Implement your login logic
try {
// Your login implementation
return { success: "Login successful" };
} catch (error) {
return { error: "Login failed" };
}
});
// Server action with validation and authentication
export const updateProfile = validatedActionWithUser(signupSchema, async (data) => {
// Here, data is validated and user is authenticated
try {
// Your profile update logic
return { success: "Profile updated successfully" };
} catch (error) {
return { error: "Failed to update profile" };
}
});
In this example, we've defined two server actions: loginEmail using validatedAction (since users won't be logged in yet) and updateProfile using validatedActionWithUser (since this requires authentication).
Using Server Actions in React Components
Finally, let's see how to use these server actions in your React components with the useFormState hook:
// LoginForm.jsx
'use client';
import { useFormState } from 'react-dom';
import { loginEmail } from '@/actions';
export default function LoginForm() {
const [state, formAction] = useFormState(loginEmail, null);
return (
<form action={formAction}>
<div>
<label htmlFor="email">Email</label>
<input type="email" id="email" name="email" required />
</div>
<div>
<label htmlFor="password">Password</label>
<input type="password" id="password" name="password" required />
</div>
{state?.error && (
<div className="error">{state.error}</div>
)}
{state?.success && (
<div className="success">{state.success}</div>
)}
<button type="submit">Login</button>
</form>
);
}
Benefits of the Middleware Pattern
- DRY (Don't Repeat Yourself): Validation and authentication logic is defined once and reused across all server actions
- Type Safety: TypeScript integration ensures that your validated data is properly typed
- Separation of Concerns: Business logic is separated from validation and authentication
- Standardized Error Handling: Consistent error format across all server actions
- Maintainability: Changes to validation or authentication logic only need to be made in one place
Customization Options
This pattern is highly customizable to fit your specific needs:
- Replace Zod with your preferred validation library (Yup, Joi, etc.)
- Customize error messages and formats
- Add additional middleware for other cross-cutting concerns like rate limiting or logging
- Extend the ActionState type to include more standardized fields
- Add custom session handling based on your authentication solution (NextAuth, Auth.js, Clerk, etc.)
Conclusion
The middleware pattern for Next.js server actions provides a clean, reusable approach to handling common concerns like data validation and user authentication. By extracting these cross-cutting concerns into reusable utilities, you can write more maintainable code that follows the DRY principle. This pattern is especially valuable in larger applications with many server actions, where consistency and maintainability are crucial.
Whether you're building a simple form or a complex application with multiple authenticated actions, this pattern can help you streamline your development process and reduce redundant code. Give it a try in your next Next.js project and experience the benefits of cleaner, more maintainable server actions.
Let's Watch!
Streamline Next.js Server Actions with Reusable Validation Middleware Pattern
Ready to enhance your neural network?
Access our quantum knowledge cores and upgrade your programming abilities.
Initialize Training Sequence