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은 어느 쪽을 쓰든 똑같이 필요합니다.
Server Action만 사용한다면 next-safe-action 이라는 이미 잘 만들어진 라이브러리가 존재합니다. 하지만 route handler에 대해서는 공통 로직을 사용할 수 없습니다.
저는 이 중복을 없애고, action이든 route든 같은 타입 규칙으로 handler를 정의할 수 있는 단일 레이어가 필요했습니다. 그 결과물이 nextjs-netdef 입니다.
모든 요청의 시작점 - callBaseBuilder
netdef의 시작은
callBaseBuilder()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는
metadataSchemanext.call()next.withCtx()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 Markernext.withCtx()type Markertype Markertype MOtype Marker런타임 데이터와 타입의 흐름
action과 route는 동일한 middleware 체인을 사용하게 됩니다. middleware는 정의된 순서대로 실행되고, 각종 validation은 middleware가 완료된 후에 실행됩니다.
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]")type AppRouteHandlerRoutes"INVALID_PARAMS""INVALID_BODY""INVALID_INPUT"action client와 route client는 둘다 일회용 Method 빌더패턴 을 사용하기 때문에, input · search parameter · body schema를 설정하는 builder method는 한번만 사용할 수 있습니다.
NextJS 16에서 stable 된 route 기능입니다. /app 에 정의된 page들의 route가
type AppRouteHandlerRoutesStandard 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이 단순히 소모되어 끝나는 것이 아니라 끝까지 흐르도록 설계하였습니다.
구현은 단일 파일로 두어 프로젝트에 복사해 쓸 수 있게 했습니다. 전체 소스는 아래에서 확인할 수 있습니다.