Next.js Server Actions decorators allow you to centralize cross-cutting concerns like auth and logging. Learn to build resilient interceptors for your mutations.
Last month, our team spent about three days tracking down a silent failure in a mutation that lacked consistent logging. We had dozens of Server Actions scattered across the codebase, each manually handling its own auth check, Zod validation, and error serialization. It was a maintenance nightmare that eventually led us to adopt a cleaner approach: the decorator pattern.
By treating our mutations as first-class citizens in a middleware pipeline, we moved cross-cutting concerns out of the business logic and into a predictable, reusable wrapper.
When you write Next.js Server Actions: Implementing Type-Safe Mutations and Middleware, it’s easy to fall into the trap of writing the same if (!user) throw Error(...) logic inside every single function. This approach is brittle. If you decide to change your logging provider or add a distributed tracing header, you have to touch every file in your actions/ directory.
We initially tried wrapping our actions in higher-order functions. While this worked, the TypeScript inference for the arguments often broke, leading to any types that defeated the purpose of our schema validation.
Instead of standard HOCs, we built a simple, chainable interface for our actions. Think of this as a "composed" pipeline. We define a generic createAction factory that accepts an array of interceptors.
TYPESCRIPTtype Interceptor = (next: (args: any) => Promise<any>) => (args: any) => Promise<any>; export function createAction<T>( schema: ZodSchema<T>, interceptors: Interceptor[], handler: (data: T) => Promise<any> ) { const baseAction = async (data: T) => { const validated = schema.parse(data); return handler(validated); }; // Reduce the interceptors to wrap the base action return interceptors.reduceRight((acc, interceptor) => interceptor(acc), baseAction); }
This structure allows us to inject behavior without modifying the core logic. For example, implementing a simple logger interceptor becomes trivial:
TYPESCRIPTconst loggerInterceptor: Interceptor = (next) => async (args) => { console.time(CE9178">'action-execution'); try { const result = await next(args); return result; } catch (error) { console.error(CE9178">'Action failed:', error); throw error; } finally { console.timeEnd(CE9178">'action-execution'); } };
In a production environment, you aren't just logging errors; you're managing state across regions. When implementing Next.js Server Actions: Implementing Idempotency and Atomic Mutations, the decorator pattern becomes essential. You can create an idempotencyInterceptor that checks a Redis cache before even hitting the database.
This keeps your business logic pure. The developer doesn't need to know how the idempotency check works; they just include the decorator in the action definition.
If you’re building Next.js Circuit Breaker Pattern: Building Resilient Server Actions, you can apply the same logic. By wrapping your database calls in an interceptor that tracks failure rates, you can stop the bleeding across your entire application instantly.
Here is how we compose these for a typical user update mutation:
The biggest downside to this approach is debugging stack traces. When you wrap functions in five different interceptors, the error stack can get deep and noisy. We had to implement a custom error formatter to clean up the Error.stack property so our Sentry logs wouldn't look like a recursive nightmare.
Another point of contention: should these interceptors run on the Edge or Node.js runtime? We found that for things like rate limiting or simple auth, the Edge is perfect. But for complex database-backed idempotency, you’ll want to ensure your interceptors are aware of your database connection pooling constraints.
I’m still not 100% satisfied with our type inference when we have more than three decorators. TypeScript’s recursive type depth limits can kick in if you aren't careful. For now, we manually define the types for the action parameters, which is a small price to pay for the architectural consistency we’ve gained.
If you’re embarking on this, start by moving your logging to an interceptor first. It’s the lowest-risk refactor and gives you an immediate win for observability. From there, you can slowly migrate your auth and validation logic into the pipeline as your comfort level grows.
Next.js Server Actions can be brittle without the right guardrails. Learn to implement type-safe mutations, Zod validation, and middleware for production.