Building Type-Safe Server Actions with next-safe-action — Middleware Authentication and Vitest Testing
Prerequisite: This article is written for readers who have experience using Server Actions in the Next.js App Router.
When Next.js App Router's Server Actions first came out, I was genuinely excited. "Finally, I can call server logic directly without API routes." But as I started writing production code, this pattern kept repeating itself for every action:
// 기존 방식 — 액션마다 이 보일러플레이트가 반복됩니다
export async function updateProfile(data: unknown) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized"); // 매번 직접 체크
const parsed = profileSchema.safeParse(data);
if (!parsed.success) return { error: parsed.error.flatten() }; // 에러 구조도 제각각
await db.user.update({ where: { id: session.user.id as string }, data: parsed.data });
}As the boilerplate piles up, you start wondering, "Is this really the right way?" next-safe-action eliminates that repetition. Authentication is defined once as middleware and reused, and type inference flows automatically from schema definition all the way down to client hooks.
// next-safe-action 방식 — 인증과 검증이 자동으로 처리됩니다
export const updateProfile = authActionClient
.schema(profileSchema)
.action(async ({ parsedInput, ctx }) => {
// ctx.user, parsedInput 모두 타입 단언 없이 완전히 추론됩니다
await db.user.update({ where: { id: ctx.user.id }, data: parsedInput });
return { success: true };
});There's a common perception that "Server Actions are hard to test." In this article, we'll challenge that perception together with a middleware isolation testing pattern using Vitest. We'll walk through the entire flow you can apply in production right away — from core concepts to authentication middleware layer design and Vitest testing.
Core Concepts
Action Client — The Foundation Where Middleware Stacks Up
Everything in next-safe-action starts with createSafeActionClient(). Each time you add middleware with .use(), it returns a new instance without mutating the original client. This lets you extend clients in layers like baseClient → authClient → adminClient while keeping shared logic DRY.
// lib/safe-action.ts
import { createSafeActionClient, ActionError } from "next-safe-action";
import { auth } from "@/lib/auth";
export const actionClient = createSafeActionClient({
handleReturnedServerError(err) {
// ActionError는 메시지를 클라이언트에 그대로 노출하고,
// 일반 Error는 이 핸들러를 통해 마스킹됩니다
if (err instanceof ActionError) return err.message;
return "서버 오류가 발생했습니다.";
},
}).use(async ({ next }) => {
console.log("[action] start");
const result = await next();
console.log("[action] end");
return result;
});
// actionClient를 확장 — 세션 여부 확인 및 ctx.user 주입
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return next({ ctx: { user: session.user } });
});
// authActionClient를 확장 — 관리자 전용
export const adminActionClient = authActionClient.use(async ({ ctx, next }) => {
if (ctx.user.role !== "admin") throw new ActionError("관리자 권한이 필요합니다.");
return next({ ctx });
});The handleReturnedServerError option of createSafeActionClient() is an important security point here. Throwing an ActionError passes its message directly to result.serverError on the client, while throwing a regular Error lets this handler intercept and mask the message. Separating errors that are safe to show users ("Admin privileges required.") as ActionError from internal system errors as regular Error results in a much cleaner security design.
Middleware Pipeline — How Information Flows Through ctx
The order you call .use() in code is the order middleware executes. The ctx passed into next() is forwarded directly to the next middleware, so the user injected in the auth middleware arrives in the action handler fully typed. And if middleware throws an error, subsequent middleware and the action handler won't execute — this sounds obvious, but confirming it yourself gives you much more confidence in designing middleware.
// actions/update-profile.ts
"use server";
import { z } from "zod";
import { authActionClient } from "@/lib/safe-action";
import { db } from "@/lib/db";
export const updateProfile = authActionClient
.schema(z.object({ name: z.string().min(1) }))
.action(async ({ parsedInput, ctx }) => {
// ctx.user는 별도 타입 선언 없이 완전히 추론됩니다
await db.user.update({
where: { id: ctx.user.id },
data: { name: parsedInput.name },
});
return { success: true, name: parsedInput.name };
});Key insight: You must
returnthe return value ofnext()inside middleware or the chain breaks. I once spent a long time debugging because I forgot this and action results were coming back asundefined. I recommend makingreturn await next()a habit.
Standard Schema v1: A spec co-designed in 2025 by the creators of Zod, Valibot, and ArkType, fully supported by next-safe-action. You can switch from Zod to Valibot without touching your action code.
Structured Error Responses
The result object returned by next-safe-action clearly separates three error types:
| Field | When It Occurs |
|---|---|
validationErrors |
Zod schema validation failure (includes per-field errors) |
serverError |
Exception thrown in middleware or action handler (ActionError exposes the message; regular Error is masked) |
fetchError |
Network-level failure |
Being able to pull per-field error messages directly with something like result.validationErrors?.name?._errors[0] on the client is quite convenient in practice.
Practical Application
Example 1: RBAC — Role Checks at the Action Level
Even when authActionClient handles authentication across the entire service, some actions may require additional permissions. In those cases, you can add middleware inline at the point of action definition. The deleteUser below is also used directly in the Vitest example that follows, so it's worth following the flow.
// actions/delete-user.ts
"use server";
import { z } from "zod";
import { authActionClient } from "@/lib/safe-action";
import { ActionError } from "next-safe-action";
import { db } from "@/lib/db";
export const deleteUser = authActionClient
.use(async ({ ctx, next }) => {
if (ctx.user.role !== "admin") {
// ActionError를 사용하면 이 메시지가 클라이언트에 그대로 전달됩니다
throw new ActionError("관리자 권한이 필요합니다.");
}
return next({ ctx });
})
.schema(z.object({ userId: z.string().cuid() }))
.action(async ({ parsedInput }) => {
await db.user.delete({ where: { id: parsedInput.userId } });
return { deleted: parsedInput.userId };
});| Component | Role |
|---|---|
authActionClient |
Verifies session exists, injects ctx.user |
Inline .use() |
RBAC check based on ctx.user.role |
ActionError |
Error message exposed to client (regular Error is masked) |
.schema() |
Input validation + type inference |
.action() |
Actual business logic |
Example 2: Testing the deleteUser Action with Vitest
Honestly, this part was the most daunting at first. Importing a file with the "use server" directive directly into Vitest will throw errors. The core strategy is to replace the auth function and DB calls with vi.mock() and call the action function directly.
First, you need to configure path aliases in vitest.config.ts so that imports like @/lib/auth work properly in the test environment. Without this configuration, none of the example code that follows will work, so make sure to check this.
// vitest.config.ts
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
environment: "node",
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./"),
},
},
});Now let's test the deleteUser action from Example 1.
// actions/delete-user.test.ts
import { vi, describe, it, expect, beforeEach } from "vitest";
import { deleteUser } from "./delete-user";
vi.mock("@/lib/auth", () => ({
auth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
db: { user: { delete: vi.fn() } },
}));
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
describe("deleteUser", () => {
beforeEach(() => vi.clearAllMocks());
it("관리자는 사용자를 삭제할 수 있다", async () => {
vi.mocked(auth).mockResolvedValue({
user: { id: "admin-1", role: "admin" },
} as any);
// Auth.js의 Session 타입은 완전히 채워진 객체를 반환하지 않아
// 테스트 픽스처에서는 as any가 불가피합니다
vi.mocked(db.user.delete).mockResolvedValue({
id: "user-1",
name: "홍길동",
email: "hong@example.com",
});
const result = await deleteUser({ userId: "cld9oixx0000008l5etfg5f3i" });
expect(result?.data?.deleted).toBe("cld9oixx0000008l5etfg5f3i");
expect(db.user.delete).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "cld9oixx0000008l5etfg5f3i" } })
);
});
it("일반 사용자가 호출하면 serverError가 반환된다", async () => {
vi.mocked(auth).mockResolvedValue({
user: { id: "user-1", role: "user" },
} as any);
const result = await deleteUser({ userId: "cld9oixx0000008l5etfg5f3i" });
// ActionError("관리자 권한이 필요합니다.")가 serverError로 래핑됩니다
expect(result?.serverError).toBeDefined();
expect(db.user.delete).not.toHaveBeenCalled();
});
it("비로그인 상태에서는 serverError가 반환된다", async () => {
vi.mocked(auth).mockResolvedValue(null);
const result = await deleteUser({ userId: "cld9oixx0000008l5etfg5f3i" });
expect(result?.serverError).toBeDefined();
expect(db.user.delete).not.toHaveBeenCalled();
});
it("유효하지 않은 userId 형식은 validationErrors를 반환한다", async () => {
vi.mocked(auth).mockResolvedValue({
user: { id: "admin-1", role: "admin" },
} as any);
const result = await deleteUser({ userId: "not-a-cuid" });
expect(result?.validationErrors).toBeDefined();
expect(db.user.delete).not.toHaveBeenCalled();
});
});If you separate middleware functions as pure functions, you can test them independently in a much cleaner way. When connecting the separated function to the client, wrap it and pass it to .use().
// lib/middlewares/auth-middleware.ts
export async function authMiddleware({
auth,
next,
ctx,
}: {
auth: () => Promise<{ user: { id: string; role: string } } | null>;
next: (args: { ctx: { user: { id: string; role: string } } }) => Promise<unknown>;
ctx: Record<string, unknown>;
}) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return next({ ctx: { ...ctx, user: session.user } });
}// lib/safe-action.ts — authActionClient를 순수 함수로 연결하는 방식
import { authMiddleware } from "@/lib/middlewares/auth-middleware";
import { auth } from "@/lib/auth";
export const authActionClient = actionClient.use(({ ctx, next }) =>
authMiddleware({ auth, ctx, next })
);With this separation, you can test just the middleware logic independently without touching the client code.
// lib/middlewares/auth-middleware.test.ts
import { vi, describe, it, expect } from "vitest";
import { authMiddleware } from "./auth-middleware";
describe("authMiddleware", () => {
it("세션이 없으면 Unauthorized 에러를 던진다", async () => {
const mockAuth = vi.fn().mockResolvedValue(null);
const mockNext = vi.fn();
await expect(
authMiddleware({ auth: mockAuth, next: mockNext, ctx: {} })
).rejects.toThrow("Unauthorized");
expect(mockNext).not.toHaveBeenCalled();
});
it("세션이 있으면 ctx에 user를 주입하고 next를 호출한다", async () => {
const user = { id: "u1", role: "user" };
const mockAuth = vi.fn().mockResolvedValue({ user });
const mockNext = vi.fn().mockResolvedValue({ data: "ok" });
await authMiddleware({ auth: mockAuth, next: mockNext, ctx: {} });
expect(mockNext).toHaveBeenCalledWith({ ctx: { user } });
});
});Testing strategy summary: Combining action integration tests (mock dependencies, call action directly) with middleware unit tests (isolated as pure functions) is an efficient way to build up coverage.
Example 3: State Management with Hooks in Client Components
// components/profile-form.tsx
"use client";
import { useAction } from "next-safe-action/hooks";
import { updateProfile } from "@/actions/update-profile";
import { toast } from "sonner";
export function ProfileForm() {
const { execute, status, result } = useAction(updateProfile, {
onSuccess: () => toast.success("저장되었습니다"),
onError: ({ error }) =>
toast.error(error.serverError ?? "알 수 없는 오류가 발생했습니다"),
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
execute({ name: formData.get("name") as string });
}}
>
<input name="name" />
{result.validationErrors?.name && (
<p className="text-red-500">
{result.validationErrors.name._errors[0]}
</p>
)}
<button type="submit" disabled={status === "executing"}>
{status === "executing" ? "저장 중..." : "저장"}
</button>
</form>
);
}Pros and Cons
Advantages
| Item | Details |
|---|---|
| End-to-end type safety | Full inference from schema definition → server handler → client hooks without manual type declarations |
| Composable middleware | Reuse auth/authorization logic DRY across baseClient → authClient → adminClient layers |
| Runtime input validation | Re-validates all client-side input on the server, conforming to OWASP recommendations |
| Validation library agnostic | Standard Schema v1 support lets you freely swap between Zod, Valibot, and ArkType |
| Structured error handling | Separates validationErrors, serverError, and fetchError for fine-grained client-side handling |
| Optimistic UI built-in | useOptimisticAction pre-renders UI before server response with automatic rollback on failure |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Learning curve | Initial time needed to learn client layer design and middleware ctx propagation patterns |
Recommended to follow the official Quick Start and middleware docs in order |
| Vitest direct testing constraints | Files with "use server" cannot run as-is in Vitest |
Address with vi.mock() dependency strategy + pure function middleware separation |
| Async Server Component testing limits | Vitest does not fully support async Server Components | Supplement with Playwright E2E testing |
| Next.js version dependency | The useStateAction hook requires Next.js 15 or higher |
Check version in legacy projects and replace with useAction |
| Abstraction overhead | Even public actions that don't need auth must go through the client structure | Recommended to keep a separate publicActionClient for public APIs. At first it felt like an extra layer, but my opinion changed when I saw client-side error handling become consistently simpler. |
Terminology — OWASP: Stands for Open Web Application Security Project, a nonprofit that defines the Top 10 web application security vulnerabilities. Not re-validating input on the server is a classic OWASP violation.
Terminology — RBAC: Role-Based Access Control. A method of controlling access permissions based on the user's role (
admin,user,moderator, etc.).
The Most Common Mistakes in Practice
-
Forgetting
return next()in middleware — Callingnext()without returning it causes the action result to be delivered asundefined. Makereturn await next()a habit. -
Placing
vi.mock()below import statements — Vitest hoistsvi.mock()to the top of the file, but declaringvi.mock()after importing the mocked module can cause unintended ordering issues. It's cleaner to placevi.mock()blocks before imports. -
Overwriting
ctxinstead of propagating it — Writingreturn next({ ctx: { user } })discards anyctxvalues accumulated by previous middleware. It's safer to spread existing context withreturn next({ ctx: { ...ctx, user } }).
Closing Thoughts
next-safe-action structurally combines type safety for Server Actions with middleware-based authentication and authorization, eliminating repetitive boilerplate and enabling testable code. Since adopting this pattern, test coverage for action files has meaningfully increased, and feedback about missing auth logic in PR reviews has noticeably dropped.
Three steps you can start with right now:
- Run
pnpm add next-safe-action@latest zodthen defineactionClientandauthActionClientinlib/safe-action.ts— if you already have Auth.js or Clerk set up, you can connect them directly. - Migrate one of your most frequently used Server Actions to
authActionClient.schema(...).action(...)— you'll immediately feel what it's like when type inference flows automatically. - Add path aliases to
vitest.config.tsand write tests for that action's auth failure and validation failure cases.
References
- next-safe-action Official Docs
- next-safe-action GitHub Repository
- Middleware Official Docs
- Quick Start Guide
- Action Client Concept
- v6 → v7 Migration Guide
- Better Auth Integration Docs
- Vitest Official Mocking Guide
- Next.js Official Vitest Setup Guide
- Standard Schema Official Site
- Next.js Security Update 2025-12-11
- next-safe-action npm Package