2026년 2월

netdef — Server Action과 Route Handler를 하나로

next-safe-action에서 출발해, middleware와 Standard Schema로 action·route를 통합한 typesafe net layer

middleware가 두 번 작성된다

Next.js에서는 같은 비즈니스 로직을 Server Action과 Route Handler 양쪽에 두어야 하는 경우가 있습니다. 폼 제출은 action으로, REST 클라이언트는 route handler로 처리하는 식입니다. 그런데 auth 확인, role 검사, 입력 검증 같은 cross-cutting concern은 어느 쪽을 쓰든 똑같이 필요합니다.

form action
fetch
Server Action
Route Handler
Cross-cutting Concern(Auth, Role, Validation 등)
Business Logic
Cross-cutting Concern 이 중복 작성된다

Server Action만 사용한다면 next-safe-action 이라는 이미 잘 만들어진 라이브러리가 존재합니다. 하지만 route handler에 대해서는 공통 로직을 사용할 수 없습니다.

저는 이 중복을 없애고, action이든 route든 같은 타입 규칙으로 handler를 정의할 수 있는 단일 레이어가 필요했습니다. 그 결과물이 nextjs-netdef 입니다.

모든 요청의 시작점 - callBaseBuilder

netdef의 시작은

callBaseBuilder()
입니다. middleware를 여기서 한 번만 정의하고, 끝에서
actionClient()
routeClient()
로 갈라집니다.

const callBase = callBaseBuilder()
  .use({
    metadataSchema: z.object({
      requireLogin: z.boolean().default(true),
    }),
    handle: async ({ metadata, next }) => {
      if (metadata.requireLogin) {
        const result = await requireAuthSafe();
        if (!result.success) {
          return result;
        }
      }
      return await next.withCtx({ user: "test" });
      // 다음 미들웨어가 사용할 ctx ->  ^^^^^^^^^^^
    },
  })
  .use({
    metadataSchema: z.object({
      requireRole: z.array(z.enum(["admin", "user", "guest", "*"])).default(["admin"]),
    }),
    handle: async ({ metadata, next, ctx }) => {
      console.log(ctx.user); // <- "user" 가 추론됨.
      if (metadata.requireRole.length > 0) {
        const result = await requireRoleSafe(...metadata.requireRole);
        if (!result.success) {
          return result;
        }
      }
      return await next.call();
    },
  });

middleware는

metadataSchema
로 호출 시 옵션을 받고,
next.call()
로 다음 단계로 넘깁니다.
next.withCtx()
를 사용하면 다음 middleware로 ctx를 전달 할 수 있습니다. 그런데 어떻게 파라미터로 넘긴 타입이, 다음 middleware의 ctx로 넘어갈 수 있을까요?

declare const __marker: unique symbol;
type Marker = { [__marker]: "Marker" };
type NotEmpty<T> = keyof T extends never ? never : T;

type Middleware<MO, MI, R, C> = { // <- 4. 반환된 CO 타입이 다음 middelware의 MO로 입력된다.
  metadataSchema?: StandardSchemaV1<MI, MO>;
  handle: (param: {
    metadata: MO;
    next: {
      call: () => Promise<void & Marker>;
      withCtx: <CO extends AnyRecord>( // <- 2. 파라미터 타입이 추론된다.
        ctxOut: NotEmpty<CO>, // <- 1. 코드에서 파라미터를 넘긴다.
      ) => Promise<CO & Marker>; // <- 3. 추론된 CO 타입이 Maker와 함께 반환된다.
    };
    ctx: Readonly<C>;
  }) => Promise<R>;
};

비결은 바로 Symbol

type Marker
입니다.
next.withCtx()
는 타입정의상 파라미터를
type Marker
와 함께 반환합니다. 반환된 타입은 내부적으로
type Marker
를 제거한 후, generic
type MO
로 다음 middleware로 전달되어, 체인을 이어갑니다.

type Marker
대한 자세한 설명은 위 영상에서 보고 응용했습니다.

런타임 데이터와 타입의 흐름

action과 route는 동일한 middleware 체인을 사용하게 됩니다. middleware는 정의된 순서대로 실행되고, 각종 validation은 middleware가 완료된 후에 실행됩니다.

Request
MW (auth)
MW (role)
validation
handler
Result
const base = routeClient.route("/api/coupons/[id]").params(
  z.object({ id: z.coerce.number().int().min(1) }),
);

export const GET = base.handler(async (_, ctx) => {
  const coupon = await couponService.getCoupon(ctx.params.id);
  if (!coupon.success) return coupon;
  return Ok(pick(coupon.data, ["name", "memo", "enabled"]));
});

route handler

.route("/api/coupons/[id]")
처럼 Next.js typed routes(
type AppRouteHandlerRoutes
)에 맞춰 path를 좁힙니다. 검증 실패 시
"INVALID_PARAMS"
,
"INVALID_BODY"
코드로 응답합니다. server action 은 검증 실패 시
"INVALID_INPUT"
코드로 응답합니다.

action client와 route client는 둘다 일회용 Method 빌더패턴 을 사용하기 때문에, input · search parameter · body schema를 설정하는 builder method는 한번만 사용할 수 있습니다.

NextJS Typed Routes

NextJS 16에서 stable 된 route 기능입니다. /app 에 정의된 page들의 route가

type AppRouteHandlerRoutes
으로 자동 정의되며, Link 컴포넌트의 href prop도 강제됩니다. dead page를 정리하거나 page path가 바뀐 경우에 타입에러가 발생하기 때문에 매우 편리합니다.

Standard Schema 지원

middleware metadata와 route handler의 param, body 그리고 action의 input은 Standard Schema로 검증합니다.

import { z } from "zod";

// zod schema example
const userSchema = z.object({
  name: z.string().min(2),
  age: z.number().int().gte(0),
});

const action = actionBuilder()
  .input(userSchema)
  .action(async (input) => {
    // input 'name'과 'age'는 zod로 검증됨
    return doSomething(input);
  });

Standard Schema를 지원함으로써 zod · Valibot · ArkType · Effect Schema 등 validator-agnostic하게 검증 로직을 작성할 수 있습니다.

정리

next-safe-action에서 출발했지만, netdef는 action과 route handler를 하나의 middleware 스택으로 묶는 데 초점을 맞췄습니다. 그 과정에서 최대한 많은 type이 단순히 소모되어 끝나는 것이 아니라 끝까지 흐르도록 설계하였습니다.

구현은 단일 파일로 두어 프로젝트에 복사해 쓸 수 있게 했습니다. 전체 소스는 아래에서 확인할 수 있습니다.

Xeonlink/next-netdef — src/index.ts