iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🛠️

Best Practices for Argument Validation in Next.js Server Functions

に公開

Introduction

Server Functions are used in React server frameworks such as Next.js.

https://ja.react.dev/reference/rsc/server-functions

It is very convenient to perform secure processing that can only be done on the server side in an RPC-like manner, such as from event handlers on the client side.
Unlike traditional API Routes in the Pages Router, functions defined on the server side can be called from the client side in a type-safe manner.
As a result, it can be handled as if the boundary between the server and the client has disappeared.

server.ts
"use server";
import { db } from "@/lib/db";
import { User } from "@/domain/types";
import { ResultAsync, success, failure } from "@/lib/result";

/** Server Function to update a user */
export const updateUser = async (id: string, user: User): ResultAsync<User, string> => {
  try {
    const updatedUser = await db.user.update({
      where: { id },
      data: user,
    });
    return success(updatedUser);
  } catch (error) {
    return failure("Failed to update user");
  }
}

However, for processing that involves side effects, such as writing to a database, security must be considered.
Argument validation is essential to prevent malicious users from writing unauthorized data.
This article introduces a technique that allows you to perform argument validation for Server Functions comfortably.

Conclusion

By creating a decorator function like the one below, you will be able to integrate validation and type inference.

server.ts
"use server";
import * as v from "valibot";
import { db } from "./db";
import { withValidate } from "./decorators";
import { type User, userSchema } from "./domain";
import { failure, type ResultAsync, success } from "./result";

export const updateUser = withValidate(
  v.string(),
  userSchema(),
)(async (id, user): ResultAsync<User, string> => {
  //     ^? (id: string, user: User)
  // Validated and the argument types are inferred!
  try {
    const updatedUser = await db.user.update({
      where: { id },
      data: user,
    });
    return success(updatedUser);
  } catch (error) {
    return failure("Failed to update user");
  }
});

Why validation is necessary

Strictly speaking, it is not necessary to perform validation for GET-based processing that does not involve writing data within Server Functions.
However, as mentioned at the beginning, for processing that involves writing data such as POST or PUT, argument validation is essential to prevent unauthorized data writing by malicious users.

You might think, "Is it unnecessary for APIs that are not public and are only intended to be called from within the application?"

However, even for APIs from within an application, it can become a risk if the behavior is reproduced.
In Next.js, Server Function URLs are obfuscated, so the possibility of the API being called unintentionally seems low.
However, if the source code is public, there is a possibility that it could be reproduced.
Since there have been cases where vulnerabilities around Server Functions were discovered and fixed in the past, don't let your guard down thinking "it's only internal, so it's fine."

Case Necessity Reason
External APIs or Webhooks 🔏Essential To prevent malicious users from writing invalid data.
Internal-only application APIs 🔏Recommended It can be a risk if the behavior is reproduced.
GET-based APIs without side effects 🔓️Not required Because they do not involve writing data.

Common Validation Implementation Examples

It is common to see implementations that manually validate Server Function arguments as follows.

server.ts
"use server";
import { db } from "@/lib/db";
import { User, isUser } from "@/domain/types";
import { ResultAsync, success, failure } from "@/lib/result";
export const updateUser = async (id: string, user: User): ResultAsync<User, string> => {
  // Check for existence of arguments
  if (!id || !user) {
    return failure("Invalid arguments");
  }

  // Type validation
  if (typeof id !== "string" || !isUser(user)) {
    return failure("Invalid argument types");
  }

  try {
    const updatedUser = await db.user.update({
      where: { id },
      data: user,
    });
    return success(updatedUser);
  } catch (error) {
    return failure("Failed to update user");
  }
};

In this example, manual validation is performed, but it presents the following issues:

  1. Need to write type definitions twice
    → Redundant and high maintenance cost

  2. Logic unrelated to the main process is mixed in
    → Unclear function responsibilities

  3. Validation logic is cumbersome
    → Can lead to missing error handling

Best Practice: Argument Validation Using the Decorator Pattern

Therefore, we can create a decorator function that wraps Server Functions, integrating argument validation and type inference.

decorators.ts
import * as v from "valibot";
import { failure, type ResultAsync } from "./result";

export const withValidate = <T extends v.TupleItems>(...schemas: T) => {
  return <R>(
    handler: (...args: v.InferOutput<v.TupleSchema<T, any>>) => ResultAsync<R, string>,
  ) => {
    return async (...args: v.InferOutput<v.TupleSchema<T, any>>): ResultAsync<R, string> => {
      const validationResult = v.safeParse(v.tuple(schemas), args);
      if (!validationResult.success) {
        return failure("Invalid arguments");
      }
      return handler(...args);
    };
  };
};

Some of you may not be familiar with the decorator pattern. Simply put, it is a function for changing the behavior of functions or classes. It takes a base function as an argument and can add processing before or after that function.

In this case, it receives the validation schema as an argument and the body of the Server Function as the second argument. It then executes the Server Function using each of the received arguments.
Validation is performed prior to the execution of the Server Function. This decorator takes care of returning an error message if validation fails. This allows you to decouple argument type inference and validation logic from the implementation of each Server Function.

"use server";
import { db } from "@/lib/db";
import { User, userSchema } from "@/domain/types";
import { ResultAsync, success, failure } from "@/lib/result";
import { withValidate } from "@/lib/decorators";
import * as v from "valibot";

export const updateUser = withValidate(
  v.string(),
  userSchema(),
)(async (id, user): ResultAsync<User, string> => {
  //     ^? (id: string, user: User)
  try {
    const updatedUser = await db.user.update({
      where: { id },
      data: user,
    });
    return success(updatedUser);
  } catch (error) {
    return failure("Failed to update user");
  }
});

Now, the argument types of Server Functions can be inferred from the argument types defined in the validation, and there is no longer a need to write the type definitions twice.

Summary

  • Validation is essential for processes that write data using Server Functions.

  • Manual validation is redundant, has low readability, and can be a cause of omissions.

  • Unifying type inference and validation with a decorator function makes the code safer and more readable.

Please try incorporating this into your future development.

GitHubで編集を提案

Discussion