Back to blog

Runtime Validation: Protecting Your React App When the BFF Changes

·3 min read
software developmentSoftware Engineeringarchitecture

Introduction

In modern web applications, your frontend TypeScript types can become silently wrong the moment your Backend for Frontend (BFF) changes its response structure—and you won't know until runtime.

The Problem

Your React app has a fundamental vulnerability: compile-time vs runtime mismatch.

When you write TypeScript, you're making assumptions about what your BFF will return:

 interface User {
  name: string;
  email: string;
}

This works beautifully during development. TypeScript checks everything. Your IDE autocompletes.

But here's what happens in production:

Monday: Your app deploys. Types say user.email is a string.Wednesday: Backend team refactors. They rename email to emailAddress.Wednesday afternoon: Your app tries to access user.email → undefinedResult: user.email.toLowerCase() → Crash. "Cannot read property 'toLowerCase' of undefined"

TypeScript didn't save you because it only exists at development time. Once compiled, your types are gone.

This creates several failure modes:

  • Silent breakage: Types are stale, runtime errors occur

  • Partial breakage: Some fields missing, app partially works

  • Type corruption: Wrong data types cause logic errors

  • Cascading failures: One service change breaks multiple features

Common Approach: Compile-Time Validation

Most teams use Swagger/OpenAPI to generate TypeScript types:

// Generated from Swagger
type UserResponse = Paths.Users.Get.Responses.$200;

function fetchUser(): Promise<UserResponse> {
  return fetch('/api/user').then(r => r.json());
}

This gives you:

  • ✅ Type safety during development

  • ✅ IDE autocomplete

  • ✅ Compile-time checks

But the critical gap:

  • ❌ No protection when actual data doesn't match the schema

  • ❌ No detection when BFF changes break the contract

  • ❌ Types disappear at runtime

The Solution: Runtime Validation

The answer is a centralized validation layer that checks actual data when it arrives.

The architecture is simple:

Every API call flows through a central layer that validates responses before they reach your page component.

When validation fails:

  1. Detect immediately - catch the problem at the boundary

  2. Degrade gracefully - show error UI, not blank screen

  3. Diagnose quickly - log exactly what went wrong

How It Works

The validation happens before the page component renders all the UI components. If validation fails, the error bubbles up to your component where you handle it appropriately.'

Code Example

Here's how it looks in practice using https://zod.dev, a TypeScript-first validation library:

Centralized API Layer:

import { z } from 'zod';

const UserSchema = z.object({
  name: z.string(),
  email: z.string(),
});

async function fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();

  // Validate at runtime
  const result = UserSchema.safeParse(data);

  if (!result.success) {
    logToMonitoring('API validation failed', result.error);
    throw new Error('Invalid user data');
  }

  return result.data; // Guaranteed to match schema
}

React Component - Error Bubbles Up:

function UserProfilePage() {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUser('123')
      .then(setUser)
      .catch(err => setError(err)); // Error caught here
  }, []);

  if (error) {
    return <div>Could not load your profile. Please try again.</div>;
  }

  return <div>Welcome, {user?.name}!</div>;
}

Validation happens once in the centralized layer, but each page decides how to present errors to users. For example, a product listing page might show cached products when validation fails, while a user profile page shows an error message.

Note: Zod is also useful for validating LLM responses in a different context. I wrote about that pattern in link-to-your-llm-blog.

Conclusion

Runtime validation isn't about preventing BFF changes—it's about protecting your app when those changes happen.

The key insight: TypeScript types are documentation that disappears at runtime. Zod (or similar libraries) creates actual runtime checks that survive into production.

With centralized validation:

  • You know immediately when contracts break (monitoring alerts)

  • Users see graceful errors, not crashes

  • Each feature handles failures appropriately

Your BFF should be free to evolve. Runtime validation ensures your frontend evolves with it—or fails gracefully when it doesn't.

Back to all posts