Form validation at scale: why client-side checks are not the full story

By Sven Lindqvist · 19 July 20268 views

A lead engineer at a mid-size e-commerce company reviews a support ticket about a checkout failure. A customer entered a discount code, the client-side validation accepted it, the order was submitted, and the backend returned an error: the discount code had already reached its usage limit. The customer's cart was cleared. The order was not placed. The engineer searches the codebase for where to add the usage-limit check to the frontend validator and realizes the architecture question has been deferred: where does each kind of validation belong?

The answer requires distinguishing between the kinds of validation that exist, which boundary each kind belongs to, and how to surface server-side errors in a way that integrates with client-side form state.

The validation taxonomy

Not all validation is the same kind of thing. Conflating the types leads to architectures where business logic ends up in the UI layer and UI feedback ends up missing from the user experience.

Structural validation checks that data is in the correct format: required fields are present, strings are not empty, numbers are within range, email addresses match a pattern. This is the validation that belongs in client-side code. It runs immediately on user input, provides instant feedback, and does not require a network round-trip.

Business rule validation checks that data satisfies domain invariants: a discount code has not exceeded its usage limit, a product quantity is available in inventory, a meeting does not overlap with an existing booking. Business rules cannot run client-side reliably because they depend on server-side state. A client-side check of the discount code limit would require fetching the current usage count, which races with concurrent submissions from other users.

Authorization validation checks that the user is permitted to perform the action they are requesting: an editor cannot publish another author's draft, a standard account cannot access premium features, a user cannot modify another user's profile. Authorization validation belongs exclusively on the server. Client-side authorization checks are a UX convenience, not a security boundary.

Uniqueness validation checks constraints that require querying a data store: a username is not already taken, an email address is not registered, a document slug does not conflict with an existing one. Like business rules, uniqueness checks require a network round-trip and race against concurrent writes.

Structural validation with Zod

Zod is the standard library for structural validation in TypeScript applications because it derives the TypeScript type from the schema, eliminating the synchronization problem between the runtime check and the type system.

import { z } from 'zod';

const CheckoutSchema = z.object({
  email: z.string().email('Enter a valid email address'),
  shippingAddress: z.object({
    line1: z.string().min(1, 'Street address is required'),
    city: z.string().min(1, 'City is required'),
    postalCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Enter a valid ZIP code'),
    country: z.string().length(2, 'Use a 2-letter country code'),
  }),
  discountCode: z.string().optional(),
  items: z.array(z.object({
    productId: z.string(),
    quantity: z.number().int().min(1, 'Quantity must be at least 1').max(99),
  })).min(1, 'Cart is empty'),
});

// TypeScript type derived directly from the schema
type CheckoutForm = z.infer<typeof CheckoutSchema>;

// Safe parse form for form submission handling
function tryValidate(data: unknown): 
  | { success: true; data: CheckoutForm } 
  | { success: false; errors: z.ZodError } {
  const result = CheckoutSchema.safeParse(data);
  if (result.success) return { success: true, data: result.data };
  return { success: false, errors: result.error };
}

The schema is the single source of truth. The TypeScript type, the runtime validator, and the error messages all derive from one definition.

Sharing schemas between client and server

The structural schema should be shared between the client and server to eliminate drift. When the server uses a different definition of "valid email" than the client, users encounter validation errors at submission that the client did not warn them about.

// packages/shared/src/schemas/checkout.ts
// Imported by both the React client and the Next.js API routes
import { z } from 'zod';

export const CheckoutSchema = z.object({
  email: z.string().email(),
  shippingAddress: z.object({
    line1: z.string().min(1),
    city: z.string().min(1),
    postalCode: z.string().regex(/^\d{5}(-\d{4})?$/),
    country: z.string().length(2),
  }),
  discountCode: z.string().optional(),
  items: z.array(z.object({
    productId: z.string(),
    quantity: z.number().int().min(1).max(99),
  })).min(1),
});

export type CheckoutInput = z.infer<typeof CheckoutSchema>;

The server still validates incoming data against the schema even when the client already validated it. The server cannot trust that the client ran the validator — or that the request came from the client at all. Shared schemas enforce consistency; they do not justify skipping server-side validation.

Business rule validation on the server

Business rules that require server-side state must execute on the server. The server validates structural constraints first (Zod), then applies business rules in parallel where possible:

export async function POST(req: Request) {
  const body = await req.json();

  // Layer 1: Structural validation — fast, no DB
  const parsed = CheckoutSchema.safeParse(body);
  if (!parsed.success) {
    return Response.json(
      { errors: { fields: formatZodErrors(parsed.error) } },
      { status: 400 }
    );
  }

  const { email, items, discountCode } = parsed.data;

  // Layer 2: Business rules — requires DB, run in parallel
  const [inventoryErrors, discountError] = await Promise.all([
    validateInventory(items),
    discountCode ? validateDiscountCode(discountCode, email) : Promise.resolve(null),
  ]);

  const fieldErrors: Record<string, string> = {};

  if (inventoryErrors.length > 0) {
    inventoryErrors.forEach(({ productId, message }) => {
      const itemIndex = items.findIndex(i => i.productId === productId);
      if (itemIndex !== -1) {
        fieldErrors[`items.${itemIndex}.quantity`] = message;
      }
    });
  }

  if (discountError) {
    fieldErrors['discountCode'] = discountError;
  }

  if (Object.keys(fieldErrors).length > 0) {
    return Response.json({ errors: { fields: fieldErrors } }, { status: 422 });
  }

  const order = await createOrder(parsed.data);
  return Response.json({ orderId: order.id }, { status: 201 });
}

The status code distinction matters: 400 means structurally invalid input that should not have passed the client. 422 means structurally valid input that failed a business rule. The client handles these differently — a 400 suggests a client-side validation bug; a 422 is an expected failure path.

Authorization validation

Authorization errors are a distinct response category from validation errors. A 403 Forbidden means the user is not permitted to perform this action at all — it is not a problem the user can fix by correcting their input.

export async function POST(req: Request) {
  const session = await getSession(req);
  if (!session) {
    return Response.json({ error: 'Not authenticated' }, { status: 401 });
  }

  // Authorization: suspended accounts cannot checkout
  if (session.user.accountStatus === 'suspended') {
    return Response.json(
      { errors: { global: 'Your account is suspended. Contact support.' } },
      { status: 403 }
    );
  }

  // Structural validation...
  const parsed = CheckoutSchema.safeParse(await req.json());
  // ...

  // Authorization: discount code may be restricted to a specific user
  if (parsed.data.discountCode) {
    const discount = await getDiscountCode(parsed.data.discountCode);
    if (discount?.restrictedToEmail && discount.restrictedToEmail !== session.user.email) {
      return Response.json(
        { errors: { fields: { discountCode: 'This code is not valid for your account.' } } },
        { status: 422 }
      );
    }
  }
}

Authorization checks run before expensive operations (inventory checks, order creation). A user who is suspended should not trigger database reads.

Async validation for uniqueness

Uniqueness checks — verifying that a username is available or that an email is not already registered — require a network call. Running them on every keystroke is expensive and generates unnecessary load. The correct pattern debounces the check and displays a loading state:

import { useState, useEffect } from 'react';

function useDebouncedValue<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);
  return debounced;
}

function UsernameField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  const debouncedValue = useDebouncedValue(value, 400);
  const [status, setStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle');

  useEffect(() => {
    if (!debouncedValue || debouncedValue.length < 3) {
      setStatus('idle');
      return;
    }

    let cancelled = false;
    setStatus('checking');

    checkUsernameAvailability(debouncedValue).then(available => {
      if (!cancelled) setStatus(available ? 'available' : 'taken');
    });

    return () => { cancelled = true; };
  }, [debouncedValue]);

  return (
    <div>
      <input
        value={value}
        onChange={e => onChange(e.target.value)}
        aria-describedby="username-status"
        aria-invalid={status === 'taken'}
      />
      <span id="username-status" role="status">
        {status === 'checking' && 'Checking availability...'}
        {status === 'available' && 'Available'}
        {status === 'taken' && 'This username is already taken'}
      </span>
    </div>
  );
}

The cancellation logic (cancelled = true in the cleanup function) prevents stale responses from overwriting a newer check's result. Without it, a slow response for "user" could overwrite the result from a faster response for "username".

The async check is advisory — the server still validates uniqueness on submission. A username that was available when the user checked might be taken by the time they submit.

Mapping server errors to form fields

The integration point between server-side validation and client-side form state is error mapping. React Hook Form provides setError for programmatically setting errors after a failed submission:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

interface ValidationErrorResponse {
  errors: {
    fields?: Record<string, string>;
    global?: string;
  };
}

function CheckoutForm() {
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<CheckoutInput>({
    resolver: zodResolver(CheckoutSchema),
  });

  const onSubmit = async (data: CheckoutInput) => {
    const response = await fetch('/api/checkout', {
      method: 'POST',
      body: JSON.stringify(data),
      headers: { 'Content-Type': 'application/json' },
    });

    if (!response.ok) {
      const body: ValidationErrorResponse = await response.json();

      if (body.errors.fields) {
        Object.entries(body.errors.fields).forEach(([field, message]) => {
          setError(field as keyof CheckoutInput, { message });
        });
      }

      if (body.errors.global) {
        // Render in a separate error banner component
        setGlobalError(body.errors.global);
      }

      return;  // Don't clear the form — the user needs to correct it
    }

    const { orderId } = await response.json();
    router.push(`/orders/${orderId}`);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} aria-invalid={!!errors.email} />
      {errors.email && <span role="alert">{errors.email.message}</span>}

      <input {...register('discountCode')} aria-invalid={!!errors.discountCode} />
      {errors.discountCode && <span role="alert">{errors.discountCode.message}</span>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Placing order...' : 'Place order'}
      </button>
    </form>
  );
}

When the server error response uses the same field path structure as the client form (discountCode, items.0.quantity), setError places the error message directly at the field. The user sees the error next to the relevant input without the cart being cleared or the page reloading.

Rate limiting as a validation boundary

Client-side validation has no defense against repeated automated submissions. Rate limiting belongs at the API layer, not in form validation logic, but it has UX implications that form code must handle:

export async function POST(req: Request) {
  const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
  const session = await getSession(req);

  const [ipOk, userOk] = await Promise.all([
    rateLimit.check(`checkout:ip:${ip}`, { limit: 5, window: '1m' }),
    session
      ? rateLimit.check(`checkout:user:${session.user.id}`, { limit: 10, window: '5m' })
      : Promise.resolve({ success: true }),
  ]);

  if (!ipOk.success || !userOk.success) {
    return Response.json(
      { errors: { global: 'Too many requests. Please wait a moment and try again.' } },
      {
        status: 429,
        headers: { 'Retry-After': '60' },
      }
    );
  }
}

The client should handle 429 responses distinctly — the form should disable the submit button temporarily and display a countdown based on the Retry-After header. Treating a 429 like a validation error (displaying it as a field error) misrepresents the problem.

The validation architecture summary

Each check belongs at a specific boundary:

CheckClient-sideServer-side
Required fieldsYesYes
Format (email, postal code)YesYes
Range (min/max quantity)YesYes
Uniqueness (username available)Advisory (debounced)Authoritative
Business rule (discount code limit)NoYes
Inventory availabilityNoYes
AuthorizationNo (UX hint only)Yes
Rate limitingNoYes

"Client-side: Yes" means the check runs before submission, providing immediate feedback. "Server-side: Yes" means the check runs at the API layer regardless of what the client did. "Advisory" means the client runs the check as a UX improvement but does not treat the result as authoritative.

The discount code failure that opened this investigation had a clear home: a business rule requiring server-side state, surfaced in the server response as a field-level error on discountCode, mapped back to the form via setError. The fix was ensuring the server response structured the error in a way the client could display next to the relevant field — rather than returning a generic 500 that cleared the cart. Validation architecture is not just about which checks exist; it is about where they live and how their results flow back to the user.

Validation error UX patterns

How errors are presented matters as much as where the validation happens. Several patterns consistently reduce user friction:

Validate on blur, not on change. Showing an error while the user is still typing ("Enter a valid email") is jarring. Validate the field when the user moves away from it, or on form submission, not on every keystroke. The exception is fields with real-time feedback like username availability, where the check itself is the UX.

Distinguish format errors from business rule errors. "Email is required" and "An account with this email already exists" are different categories. Format errors belong inline next to the field. Business rule errors that affect the overall form (not just one field) belong in a summary at the top of the form or in a toast, so they are visible even if the user has scrolled past the relevant field.

Preserve form state on server errors. The checkout cart clearing on a discount code error was avoidable. On any non-5xx server response, the form should remain populated — the user made an error, not a mess. Only redirect or clear on explicit success (201 Created, 200 OK with the expected payload).

Accessible error attribution. aria-invalid="true" on the input and aria-describedby linking to the error message ensure that screen readers announce the error when the field receives focus. Both attributes should be set programmatically when the error is set, and cleared when the error is resolved.

// Accessible error pattern
<div>
  <label htmlFor="discount-code">Discount code</label>
  <input
    id="discount-code"
    {...register('discountCode')}
    aria-invalid={!!errors.discountCode}
    aria-describedby={errors.discountCode ? 'discount-code-error' : undefined}
  />
  {errors.discountCode && (
    <span id="discount-code-error" role="alert">
      {errors.discountCode.message}
    </span>
  )}
</div>

role="alert" causes screen readers to announce the error immediately when it appears, without requiring the user to refocus the field. Use it only for errors that appear after a user action (submission, blur), not for errors that are pre-populated on load.

Comments

No comments yet. Be the first!

Sign in to leave a comment.