Next.js Server Actions Verification Design Playbook
Server Actions A practical guide that improves operational stability by separating input verification, authority checking, and error mapping.

Introduction
By introducing Server Actions, the API Route can be reduced, speeding up development. The problem is that verification rules, authorization rules, and error response rules are easy to get mixed up during the operation stage. In particular, as the number of actions increases, try/catch overwrites the business logic, and it becomes difficult to track “where the failure occurred” at the time of failure analysis.
This article organizes practical patterns that separate input validation, permission checking, use case execution, and error mapping based on one action. The goal is “a structure where quality does not deteriorate even when developers add actions.”

Problem definition
Failure patterns frequently encountered in the field are as follows.
- Form input verification is implemented differently on the client and server, breaking data consistency.
- Permission checks are scattered within the use case, resulting in omissions when changing policies.
- User messages are inconsistent as DB errors or external API errors are exposed.
- It is difficult to reproduce because there is no request context in the failure log.
The key is to view action functions as “little orchestrators” and separate each responsibility like an adapter.
Key concepts
| layer | responsibility | Response in case of failure |
|---|---|---|
| Input Validator | 타입/스키마 검증 | 400 + 필드 단위 에러 |
| Policy Guard | Authentication/Authorization Check | 401/403 + 정책 코드 |
| UseCase | 도메인 규칙 실행 | 도메인 예외로 래핑 |
| Error Mapper | 사용자 응답/로그 포맷 | 표준 에러 모델 반환 |
운영 기준은 단순하다. The action does not directly touch the DB and only calls UseCase. Actions only respond with common types such as ActionResult<T>.
Code example 1: Separating action entry points
"use server";
import { z } from "zod";
import { getSession } from "@/lib/auth/session";
import { createPostUseCase } from "@/app/_application/post-usecase";
import { mapActionError, type ActionResult } from "@/app/_application/action-error";
const schema = z.object({
title: z.string().min(8).max(120),
body: z.string().min(50),
tags: z.array(z.string()).max(8),
});
type CreatePostOutput = { postId: string };
export async function createPostAction(
formData: FormData,
): Promise<ActionResult<CreatePostOutput>> {
try {
const parsed = schema.parse({
title: formData.get("title"),
body: formData.get("body"),
tags: formData.getAll("tags"),
});
const session = await getSession();
if (!session) return { ok: false, code: "UNAUTHENTICATED", message: "로그인이 필요합니다." };
if (!session.roles.includes("editor")) {
return { ok: false, code: "FORBIDDEN", message: "작성 권한이 없습니다." };
}
const output = await createPostUseCase.execute({
actorId: session.userId,
...parsed,
});
return { ok: true, data: { postId: output.postId } };
} catch (error) {
return mapActionError(error, { feature: "createPostAction" });
}
}
코드 예시 2: 표준 에러 매퍼
import { ZodError } from "zod";
type ErrorCode =
| "VALIDATION_ERROR"
| "UNAUTHENTICATED"
| "FORBIDDEN"
| "CONFLICT"
| "INTERNAL_ERROR";
export type ActionResult<T> =
| { ok: true; data: T }
| { ok: false; code: ErrorCode; message: string; details?: unknown };
export function mapActionError(error: unknown, ctx: { feature: string }): ActionResult<never> {
if (error instanceof ZodError) {
return {
ok: false,
code: "VALIDATION_ERROR",
message: "입력값을 다시 확인해주세요.",
details: error.flatten(),
};
}
console.error("action.error", { feature: ctx.feature, error });
return {
ok: false,
code: "INTERNAL_ERROR",
message: "잠시 후 다시 시도해주세요.",
};
}
Architecture flow
Tradeoffs
- Dividing schema/guard/mapper for each action increases the initial code volume.
- Conversely, the failure analysis time is greatly reduced. This is because the error code and log schema are unified.
- As the team grows, the value of “enforcing common rules” increases. 신규 개발자의 편차를 줄일 수 있다.
Cleanup
The success or failure of Server Actions is determined not by grammar but by boundary design. By keeping actions thin and separating verification, authorization, use cases, and error mapping, operational difficulty remains linear as the number of actions increases. The next step is to convert the client error UI into a common component based on ActionResult.
Image source
- Cover: source link
- License: GPLv3 / Author: Waldenn
- Note: After downloading the free license image from Wikimedia Commons, it was optimized to JPG at 1600px.