Home/Architecture/Next.js server actions via next-safe-action
Transport layer

Next.js server actions via next-safe-action

Composable middleware, schema validation, and a single catch boundary via next-safe-action.

Context

Server actions are the transport layer, the equivalent of controllers in an MVC stack. Without a shared factory, each action ends up with its own try/catch, its own session check, and its own response shape. That duplication diverges fast.

The decision

All server actions are wired via `next-safe-action`. An `actionClient` is configured once with `handleServerError` as the single catch boundary. Each action chains `.use()` middleware for auth, rate limiting, and feature flags, then declares its input schema via `.inputSchema()`. Module services return DTOs directly, so actions never call `getValueOrThrow()`. Server actions are POST-only; reads use server-side loaders.

Rationale

  • 1

    `.use(withAuth)` resolves the session before the handler runs and injects it into `ctx`. A missing or invalid session gets rejected before the handler is ever called.

  • 2

    `handleServerError` is the single catch boundary. It maps thrown errors via `toErrorResponse` and returns `{ code, message }` as `result.serverError`. No per-action error handling needed.

  • 3

    `toErrorResponse` maps `DomainException`, Prisma errors, and unexpected errors to stable external codes. The client never sees stack traces or internal exception names.

  • 4

    The `handleActionResponse()` utility bridges next-safe-action results to TanStack Query. It checks `result.serverError` and throws `ActionError(code, message)`, which TanStack Query catches for retry and global error handling.

In the codebase

Unprotected action. Rate-limited with input schema

'use server';

const loginAction = actionClient
  .metadata({ actionName: 'loginUser' })
  .use(withRateLimit)
  .inputSchema(loginUserSchema)
  .action(async ({ parsedInput }) => {
    return identityService.loginUser(
      parsedInput.email,
      parsedInput.password,
    );
  });

Protected action. Auth, feature flag, and input schema

'use server';

const createBudgetAction = actionClient
  .metadata({ actionName: 'createBudget' })
  .use(withAuth)
  .use(withFeatureFlag(FEATURE_KEYS.BUDGET_WRITE))
  .inputSchema(createBudgetSchema)
  .action(async ({ ctx, parsedInput }) => {
    return budgetsService.createBudget(
      ctx.userId,
      parsedInput.category,
      parsedInput.monthlyLimit,
    );
  });

actionClient setup with handleServerError + handleActionResponse bridge

// actionClient. Single catch boundary + tracing middleware
const actionClient = createSafeActionClient({
  defineMetadataSchema: () => z.object({ actionName: z.string() }),

  handleServerError: (error): ErrorResponse => {
    logger.error(error);
    return toErrorResponse(error);
  },
}).use(async ({ metadata, next }) => {
  const span = tracer.startSpan(`action.${metadata.actionName}`);
  try {
    const result = await next();
    span.end();
    return result;
  } catch (error) {
    span.recordException(error as Error);
    span.setStatus({ code: SpanStatusCode.ERROR });
    span.end();
    throw error;
  }
});

// handleActionResponse. Bridges next-safe-action to TanStack Query
const handleActionResponse = async <T>(
  response: Promise<SafeActionResponse<T>>,
): Promise<T> => {
  const result = await response;

  if (result.serverError) {
    throw new ActionError(result.serverError.code, result.serverError.message);
  }

  if (result.validationErrors) {
    throw new ActionError('VALIDATION_ERROR', 'The request contains invalid data.');
  }

  return result.data as T;
};

Tradeoffs

Middleware is composable. `.use(withAuth).use(withRateLimit)` reads like a pipeline and each concern is independently testable.

next-safe-action is a third-party dependency. A breaking change in its API affects every action simultaneously.

Schema validation, session resolution, and error mapping are configured once on the `actionClient`, not per action.

Engineers unfamiliar with next-safe-action need to understand the `.use()` chaining and `handleServerError` flow before they can reason about any action.