2 min read

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.

Next.js Server Actions Verification Design Playbook thumbnail

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.”

Next.js Server Actions 검증 설계 플레이북 커버
Wikimedia Commons 기반 무료 이미지

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

layerresponsibilityResponse in case of failure
Input Validator타입/스키마 검증400 + 필드 단위 에러
Policy GuardAuthentication/Authorization Check401/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

Mermaid diagram rendering...

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.

Comments