A Type-Safe Pipeline for Adding Authentication and Rate Limiting to Next.js Server Actions with `next-safe-action`
When I first tried Server Actions in Next.js, I thought "oh, this is convenient" — but when it came time to ship to production, I hit a wall. I found myself copy-pasting auth check code into every action, unable to figure out where or how to add rate limiting, and quietly reaching for any to paper over unclear types. You've probably been there too.
One more honest admission: a Server Action exposed without authentication is effectively a public API endpoint. It's easy to be lulled by Next.js's convenience into thinking "it's only called from the client, so it should be fine" — but a malicious actor can invoke your action directly with a single curl command. If an action that touches your DB directly is exposed without rate limiting, you're looking at not just DDoS risk, but potential data leakage as well.
next-safe-action tackles that problem head-on. Through a type-safe middleware pipeline, you can declaratively bundle authentication, rate limiting, and input validation in one place. By the end of this article, you'll be able to define a Server Action with auth and rate limiting in under 30 lines, and have a structure where every new action you add automatically gets the security pipeline applied — no repetitive boilerplate required. We'll walk through the entire flow step by step: starting from basic client setup, layering in an auth middleware and Upstash-based rate limiting, and wiring it up to a real action and React component.
Core Concepts
Prerequisites and Version Info
This article assumes you're already working with the following three things:
- Next.js App Router — an environment with Server Actions
- TypeScript strict mode — to take full advantage of type safety
- An auth library — the examples use Better Auth, but any library works the same way: fetch the session and pass it into
next({ ctx })
The current latest version is v8.1.8 (as of May 2026). There were breaking changes between v6→v7→v8, so if you're introducing this into an existing codebase, it's recommended to check the official migration guide first.
Action Client — The Starting Point of the Pipeline
Everything in next-safe-action starts from createSafeActionClient(). You chain middleware off this client instance and ultimately define individual actions from it.
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
export const actionClient = createSafeActionClient({
handleServerError(e) {
if (e instanceof Error) return e.message;
return "An unexpected error occurred.";
},
});
handleServerError: A hook that processes errors thrown inside Server Actions before exposing them to the client. It prevents stack traces and sensitive internal information from leaking out raw. In production, it's a good idea to wire up an error logging service (like Sentry) here.
In practice, the pattern is to use this base client as a root and extend it in a tree structure. You create a client for actions that require authentication, then a client on top of that with rate limiting added — layered hierarchically, so each action can pick the right client for its situation.
Middleware Chain — .use() and .useValidated()
There are two middleware methods, and they run at different points in time. I initially ignored the difference and put everything in .use(), only to get a type error when I tried to access parsedInput before validation had run.
| Method | When it runs | Primary use |
|---|---|---|
.use() |
Before input validation | Auth session checks, rate limiting, logging |
.useValidated() |
After input validation | Resource ownership checks, business logic guards |
The pipeline execution order looks like this:
.use() middlewares → Input validation (schema) → .useValidated() middlewares → .action()The key thing about .useValidated() is that you receive parsedInput already fully type-inferred. When passing context to the next middleware with the next() function, use return next({ ctx }) to keep the context as-is, or return next({ ctx: { ...ctx, newProp } }) to add new values. Overwriting the entire ctx in one go will cause a bug where values set by previous middleware disappear.
Standard Schema v1 — Freedom to Choose Your Validator
Standard Schema v1 is a shared interface specification implemented by multiple validator libraries in the TypeScript ecosystem (Zod, Valibot, ArkType, etc.). Starting from
next-safe-actionv8, support for this spec means you're not locked into any particular library — you can use whichever validator your team prefers.
The example code uses Zod, but swapping it for Valibot or ArkType works identically.
When to Use It
If any of the following apply, it's worth considering:
- You're repeating the same auth check code across multiple Server Actions
- You need rate limiting but aren't sure where or how to add it
- You want to infer the action's input/output types directly on the client
- You want to leave existing Server Actions untouched and improve only new ones incrementally
On the flip side, if it's a simple CRUD endpoint with no auth and open public access, this might be over-engineering.
Practical Application
Setting Up the Server-Side Pipeline
Example 1: Base Client and Auth Middleware
I'll use Better Auth as the reference point. For new projects, Better Auth is currently the most natural choice, and the next-safe-action official docs include a dedicated integration guide. That said, the approach is the same with NextAuth or Clerk — fetch the session and pass it into next({ ctx: { user, session } }).
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
import { headers } from "next/headers";
import { auth } from "@/lib/auth"; // Better Auth instance
// Step 1: Base client
export const actionClient = createSafeActionClient({
handleServerError(e) {
if (e instanceof Error) return e.message;
return "An unexpected error occurred.";
},
});
// Step 2: Auth client (blocks immediately if no session)
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) throw new Error("Unauthorized");
return next({
ctx: {
user: session.user,
session: session.session,
},
});
});| Code point | Description |
|---|---|
auth.api.getSession() |
Better Auth's session fetch API. Full type inference including plugin types |
headers() |
Passes request headers via Next.js's headers() function. Requires await (Next.js 15+) |
throw new Error("Unauthorized") |
Errors thrown in middleware pass through handleServerError before reaching the client |
next({ ctx: ... }) |
Accessible as ctx.user, ctx.session in subsequent middleware and .action() |
Example 2: Adding Rate Limit Middleware (Upstash)
I initially tried attaching rate limiting directly to actionClient, but if the limit triggers before authentication, you don't have the user ID available. So the right pattern is to chain it after the auth middleware. Since ctx.user.id is already available at that point, you can use the authenticated user's ID as the identifier instead of an anonymous IP.
// lib/safe-action.ts (continued)
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
// Redis.fromEnv() reads the UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN env vars
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "60s"), // sliding window of 60s, max 5 requests
});
export const rateLimitedAuthClient = authActionClient.use(
async ({ next, ctx }) => {
const { success } = await ratelimit.limit(ctx.user.id);
if (!success) throw new Error("Too many requests. Please try again later.");
return next({ ctx }); // propagate ctx as-is
}
);Sliding Window: Rather than a fixed time interval, this calculates from the current moment backward by N seconds. Compared to Fixed Window, it more effectively controls burst traffic at boundary points.
When setting up Upstash for the first time, you need to add UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN to .env.local. Both values are immediately visible after creating a Redis instance in the Upstash dashboard. Typos in environment variable names are a very common cause of failing on the first connection, so keep your console open.
| Code point | Description |
|---|---|
Redis.fromEnv() |
Auto-loads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN env vars |
Ratelimit.slidingWindow(5, "60s") |
Allows up to 5 requests in a 60-second sliding window |
ratelimit.limit(ctx.user.id) |
User ID-based limiting — more spoof-resistant than IP-based |
return next({ ctx }) |
Passes ctx to the next stage without modification |
Example 3: Defining an Action — Ownership Check and Profile Update
We attach a schema and action body to the rateLimitedAuthClient we built earlier. Since .useValidated() runs after schema validation, you have full typed access to parsedInput. To avoid the experience of "you said you'd show an ownership check but the code is empty," here's a real ownership check example.
// actions/update-profile.ts
"use server";
import { z } from "zod";
import { rateLimitedAuthClient } from "@/lib/safe-action";
import { db } from "@/lib/db"; // Prisma client
export const updateProfileAction = rateLimitedAuthClient
.schema(
z.object({
userId: z.string(),
name: z.string().min(1).max(50),
bio: z.string().max(200).optional(),
})
)
.useValidated(async ({ next, parsedInput, ctx }) => {
// This is where we can access validated parsedInput — ownership check
if (parsedInput.userId !== ctx.user.id) {
throw new Error("Forbidden: You cannot edit another user's profile.");
}
return next({ ctx });
})
.action(async ({ parsedInput, ctx }) => {
// ctx.user, ctx.session are fully type-inferred
await db.user.update({
where: { id: ctx.user.id },
data: {
name: parsedInput.name,
bio: parsedInput.bio, // optional(), so can be undefined — Prisma excludes undefined fields from updates
},
});
return { success: true };
});| Code point | Description |
|---|---|
Ownership check in .useValidated() |
parsedInput.userId !== ctx.user.id blocks modification of another user's resource |
return next({ ctx }) |
Propagates ctx as-is. To add values, use { ctx: { ...ctx, extra } } |
parsedInput.bio |
optional(), so can be undefined. Prisma excludes undefined fields from updates |
Client Integration
Example 4: Calling with the useAction Hook in a React Component
The onError callback of the useAction hook receives two types of errors together. validationErrors is for Zod schema validation failures; serverError is for errors thrown in middleware or the action itself. Since we're using a type-safe library, handling the two distinctly gives users much clearer feedback.
// components/profile-form.tsx
"use client";
import { useAction } from "next-safe-action/hooks";
import { updateProfileAction } from "@/actions/update-profile";
import { toast } from "sonner";
export function ProfileForm({ userId }: { userId: string }) {
const { execute, isExecuting } = useAction(updateProfileAction, {
onSuccess: () => toast.success("Profile updated!"),
onError: ({ error }) => {
if (error.validationErrors) {
// Zod schema validation failure — can handle per-field error messages
toast.error("Please check your input and try again.");
} else {
// Server error thrown from middleware or action
toast.error(error.serverError ?? "An error occurred.");
}
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
execute({
userId,
name: data.get("name") as string,
bio: (data.get("bio") as string | undefined) || undefined, // handle optional field
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" type="text" />
<textarea name="bio" />
<button type="submit" disabled={isExecuting}>
{isExecuting ? "Saving..." : "Save"}
</button>
</form>
);
}Applying Integrated Security Without Redis
Example 5: Rate Limiting, Bot Detection, and WAF All at Once with Arcjet
If maintaining separate Redis infrastructure feels like too much overhead, Arcjet is a solid alternative. It handles rate limiting, bot detection, and WAF in one place, making the setup quite concise.
// lib/safe-action.ts (Arcjet version)
import arcjet, { shield, detectBot, fixedWindow } from "@arcjet/next";
import { headers } from "next/headers";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
shield({ mode: "LIVE" }), // WAF — blocks SQL injection, XSS, etc.
detectBot({ mode: "LIVE", allow: [] }), // blocks bot traffic
fixedWindow({ mode: "LIVE", window: "60s", max: 5 }), // rate limiting
],
});
export const secureActionClient = actionClient.use(async ({ next }) => {
const decision = await aj.protect(await headers());
if (decision.isDenied()) throw new Error("Request blocked.");
return next({});
});Note:
aj.protect(await headers())is a simplified example. Depending on the version, the Arcjet Next.js SDK may require aNextRequestobject or use a separate helper. It's recommended to check the official Arcjet Next.js example before applying this in production.
Pros and Cons
Advantages
| Item | Description |
|---|---|
| Full type inference | TypeScript follows along from schema → middleware ctx → action body with no manual type declarations |
| Separation of concerns | Auth, rate limiting, and logging are separated into middleware, eliminating boilerplate in each action |
| Validator flexibility | Standard Schema v1 support means you can choose from Zod, Valibot, or ArkType |
| Built-in React hooks | useAction and useOptimisticAction handle loading states and optimistic updates cleanly |
| Incremental adoption | Can be applied selectively to new actions without touching existing ones |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Confusion with Next.js middleware | middleware.ts (Edge Runtime) and .use() middleware are completely separate |
Always perform auth checks inside Server Actions |
| Frequent API changes | Breaking changes exist between v6→v7→v8 minor/major versions | Pin your version and refer to the official migration guide |
| IP extraction complexity | Server Actions don't expose the Request object directly |
Extract via headers(). On Vercel use x-real-ip, on Cloudflare use CF-Connecting-IP |
| Middleware ordering constraint | Cannot add .use() after .useValidated() |
Register all pre-validation middleware with .use() first |
Edge Runtime vs Node.js Runtime: Next.js's
middleware.tsruns on the Edge Runtime — fast responses, but restricted Node.js API access. Server Actions run on the Node.js Runtime, so DB access and similar operations are unrestricted. It's best not to mix the roles of these two layers.
The Most Common Mistakes in Practice
-
Putting all auth logic in Next.js
middleware.ts: Session validation in the Edge Runtime is not a real security boundary. Server Actions must always re-validate internally, and that's exactly whatnext-safe-action's middleware is for. -
Rate limiting by IP only, before authentication: In proxy or CDN environments, the
x-forwarded-forheader can be spoofed. Placing rate limiting after the auth middleware and restricting by user ID is far more robust. -
Doing ownership checks inside
.action()instead of.useValidated(): It works, but concerns get mixed together. Logic that needs validated input — like ownership and permission checks — belongs in.useValidated(), which keeps the action body much cleaner.
Closing Thoughts
Once this structure is in place on your project, something changes. When adding a new Server Action, you no longer need to write authentication, rate limiting, and ownership checks from scratch each time. Pick rateLimitedAuthClient, attach your schema and action body, and the security pipeline follows automatically. During code review, the question "did you add auth?" naturally disappears.
Three steps you can take right now:
- Install dependencies with
pnpm add next-safe-action zod, and create a baseactionClientinlib/safe-action.ts. Just attachinghandleServerErrorgives you a far safer starting point than bare actions. - Pick the most important Server Action in your project and migrate it to
authActionClient. Experiencing types flowing through ctx automatically will likely give you a "why didn't I know about this sooner" moment. - Add
pnpm add @upstash/ratelimit @upstash/redis, create a Redis instance in the Upstash dashboard, and setUPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKENin.env.local. For prototypes or small-scale services, the free tier is enough to get started. Typos in env var names are a very common cause of failing on first connection, so keep your console open.
References
- next-safe-action official docs
- Middleware | next-safe-action official docs
- Better Auth | next-safe-action official integration guide
- GitHub - TheEdoRan/next-safe-action
- Migration from v6 to v7 | next-safe-action
- Next.js Server Actions Security: 5 Vulnerabilities You Must Fix — MakerKit
- Next.js server action security — Arcjet Blog
- GitHub - arcjet/example-nextjs-server-action
- Rate-limiting Server Actions in Next.js | Next.js Weekly
- Rate Limiting Next.js API Routes using Upstash Redis | Upstash Blog
- GitHub - upstash/ratelimit-js