Validating Incoming Data

Introduction: Why Validate Incoming Data?

In the previous lesson, you learned how to build consistent API responses so that every request returned a predictable structure. In this lesson, we’ll take the next important step toward building a reliable backend — validating incoming data.

When your API receives data from clients (for example, creating or updating a user), you can’t assume that data is valid or complete. Someone might send:

  • an empty name,
  • an invalid email address,
  • or even omit required fields entirely.

If your backend doesn’t validate data before using it, it could save bad information to memory or a database, crash unexpectedly, or behave in unsafe ways.

Validation acts as a gatekeeper, checking every incoming request and ensuring only clean, well-structured data makes it into your system.

By the end of this lesson, you’ll know how to:

  • Use a centralized validation module (app/lib/validation.ts),
  • Integrate it into your API routes,
  • And observe validation behavior directly in the preview UI.

Understanding the Purpose of Validation

Data validation means verifying that incoming information matches your expectations before you use it.

In your API, this applies to any operation where users send data — especially POST, PUT, and PATCH requests that create or update users.

Validation answers these questions:

  • Is this field required?
  • Is the value the correct type (string, number, boolean)?
  • Does it match allowed values (like "admin" or "user" for a role)?
  • Does it have the right format (like an email address)?

For example:

  • A name should not be empty.
  • An email should look like "person@example.com".
  • The role should be one of a specific set of roles.
  • The isActive flag should be a boolean, not a string like "yes".

By centralizing this logic in one file, you ensure all routes follow the same rules and produce clear error messages that both developers and users can understand.

The Validation Module: app/lib/validation.ts

Your validation logic lives in a single, reusable module that defines rules for checking user input. Let’s look at the core of this file.

const ALLOWED_ROLES = ["user", "admin", "moderator"] as const;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const VALID_KEYS = new Set(["name", "email", "role", "isActive"]);

type ValidationOptions = { requireAll?: boolean };

export function validateUserPayload(
  payload: unknown,
  opts: ValidationOptions = {}
): string[] {
  const errors: string[] = [];
  const { requireAll = false } = opts;

  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
    return ["Invalid request body. Expected a JSON object."];
  }

  const record = payload as Record<string, unknown>;
  const has = (k: string) => Object.prototype.hasOwnProperty.call(record, k);

  if (!requireAll) {
    const hasValidField = Array.from(VALID_KEYS).some((k) => has(k));
    if (!hasValidField) {
      errors.push("Provide at least one of name, email, role, or isActive.");
    }
  }

  if (requireAll || has("name")) {
    const rawName = record.name;
    if (typeof rawName !== "string" || rawName.trim() === "") {
      errors.push("'name' must be a non-empty string.");
    }
  }

  if (requireAll || has("email")) {
    const rawEmail = record.email;
    if (typeof rawEmail !== "string" || rawEmail.trim() === "") {
      errors.push("'email' must be a non-empty string.");
    } else if (!EMAIL_PATTERN.test(rawEmail.trim())) {
      errors.push("'email' must be a valid email address.");
    }
  }

  if (has("role")) {
    const rawRole = record.role;
    if (typeof rawRole !== "string" || rawRole.trim() === "") {
      errors.push("'role' must be a non-empty string if provided.");
    } else {
      const normalizedRole = rawRole.trim();
      if (!ALLOWED_ROLES.includes(normalizedRole as (typeof ALLOWED_ROLES)[number])) {
        errors.push(`'role' must be one of: ${ALLOWED_ROLES.join(", ")}.`);
      }
    }
  }

  if (has("isActive")) {
    const rawIsActive = record.isActive;
    if (typeof rawIsActive !== "boolean") {
      errors.push("'isActive' must be a boolean.");
    }
  }

  return errors;
}

What This Function Does

This function is the core validator for all user data operations in your backend.

  • Checks that the body is a plain object.
    If someone sends an array, a string, or an invalid JSON body, the validator immediately returns an error message.

  • Enforces required fields when needed.
    When creating or replacing a user (POST or PUT), required fields such as name and email must be present.
    Optional fields like role and isActive are validated only if provided.
    When partially updating (PATCH), at least one valid field must be included.

  • Validates individual fields.

    • name: must be a non-empty string.
    • email: must be a string and match a simple email regex pattern.
    • role: optional, but if provided, must be "user", "admin", or "moderator".
    • isActive: if provided, must be a boolean (true or false).
  • Returns an array of messages.
    Each message describes exactly what’s wrong with the input.
    This array is then passed to your err() helper, which joins them into a readable string for the frontend.

The result is a single function that can be reused in every route that processes user data.

Integrating Validation into API Routes

Validation becomes part of your route logic in app/routes/api.users.tsx and app/routes/api.users.$id.tsx.

Example: POST /api/users in api.users.tsx

export async function action({ request }: ActionFunctionArgs) {
  if (request.method !== "POST") return err("Method Not Allowed", 405);

  try {
    const body = await request.json();
    const errors = validateUserPayload(body, { requireAll: true });
    if (errors.length > 0) {
      return err(errors, 400);
    }

    const nextId = users.length ? Math.max(...users.map(u => u.id)) + 1 : 1;
    const created: User = {
      id: nextId,
      name: String(body.name).trim(),
      email: String(body.email).trim(),
      role: body.role ? String(body.role).trim() : "user",
      isActive: typeof body.isActive === "boolean" ? body.isActive : true,
      createdAt: new Date().toISOString()
    };

    users.push(created);
    return ok(created, 201);
  } catch {
    return err("Invalid JSON body", 400);
  }
}

How This Works

  • The route first parses the JSON body.
  • It calls validateUserPayload() to check the data.
  • If any validation fails, the function returns an array of errors.
  • The err() helper receives that array and formats it into a single readable error message before sending it back to the client.

For example, sending this invalid body:

{
  "email": "invalidemail",
  "role": "guest"
}

will produce this consistent backend response:

{
  "status": "error",
  "error": {
    "message": "'name' must be a non-empty string.; 'email' must be a valid email address.; 'role' must be one of: user, admin, moderator."
  }
}

The frontend can now display these messages clearly without guessing the error shape.

How the Validation Appears in the Preview UI

Your Remix UI is designed to visualize backend behavior rather than prevent it.

When you interact with the interface:

  • The input fields (Name, Email, Role, etc.) accept any value, even invalid ones.
  • The red text area above the JSON preview shows validation or server error messages returned by the backend.
  • The <pre> block below it shows the complete JSON response, even when errors occur.

This design is intentional. It lets you:

  • Observe how backend validation works directly.
  • Compare raw JSON responses with user-facing error messages.
  • Experiment with edge cases like empty inputs, invalid emails, or missing fields.

For example:

Submitting an empty Name and Email will show a red alert reading:
"'name' must be a non-empty string.; 'email' must be a non-empty string."

The <pre> block will display the full backend response object, showing the standardized error structure.

By separating frontend display and backend validation, you ensure that the backend remains the single source of truth for input validation.

Why the Validation Files Are Needed

validation.ts

  • Defines all backend validation logic.
  • Keeps validation rules centralized.
  • Prevents duplication across routes.
  • Makes the API safer, since only valid data is processed.

responses.ts

  • Ensures every validation error follows a consistent response format.
  • Converts arrays of validation messages into a single readable message.
  • Guarantees that every API response — success or failure — shares the same structure.

Frontend (_index.tsx)

  • Displays validation messages without performing them locally.
  • The UI’s job is to show what the backend says, not decide what’s valid.

Together, these files demonstrate a real-world pattern: backend validation, unified responses, and a client that faithfully reports what the server returns.

Summary

In this lesson, you learned:

  • How to validate incoming data with the validateUserPayload() function.
  • Why validation should happen on the backend instead of the frontend.
  • How your Remix UI displays backend validation errors clearly.
  • How consistent responses and validation make your API more predictable and professional.

By moving validation into a single backend module, you created a foundation for stronger, safer APIs.
This is exactly how professional teams handle input validation in scalable systems.

Next, you’ll learn how to extend this pattern with structured logging and more robust error handling to make your backend easier to monitor and debug.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal