APIs evolve. UIs often break when field names shift, enums grow, or nullability changes. An API‑first approach flips the dependency: your React components adapt to a contract (OpenAPI/GraphQL/JSON Schema), not to ad‑hoc responses. In this guide you’ll build schema‑aware components that tolerate change—using adapters, validation, and type generation—with performance and a11y in mind.
What you’ll get: patterns for schema validation (Zod), adapters that decouple server changes, TanStack Query caching, feature flags, error boundaries, and CI checks that gate incompatible API changes.
Table of Contents
- Contracts over assumptions
- Generate types from OpenAPI/GraphQL
- Validate at the edge of your app (Zod)
- Adapters: the anti‑corruption layer
- Schema‑aware components
- Handling versioning, flags, and deprecations
- Data fetching with TanStack Query
- Error boundaries & resilient UX
- Performance: caching, pagination, and streaming
- Testing & CI: schema diffs and contract tests
- Reference folders
- FAQ
Contracts over assumptions
Prefer machine‑readable contracts and generate types instead of hand‑rolling interfaces.
- OpenAPI: REST endpoints + schemas.
- GraphQL: typed schema, introspection, fragments.
- JSON Schema: common for event payloads and form builders.
Keep contracts in the same repo or versioned separately with a published package.
Generate types from OpenAPI/GraphQL
# OpenAPI → TS types + clients (example)
pnpm add -D openapi-typescript
pnpm openapi-typescript https://api.example.com/openapi.json -o src/types/openapi.d.ts
# GraphQL → codegen
pnpm add -D @graphql-codegen/cli
pnpm graphql-codegen initUse fragments (GraphQL) or components/schemas (OpenAPI) to avoid repeating shapes.
Validate at the edge of your app (Zod)
Generate types for compile‑time, but validate at runtime near the network boundary.
// src/schemas/user.ts
import { z } from "zod";
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email().nullable().default(null),
plan: z.enum(["free", "pro", "enterprise"]).default("free"),
});
export type User = z.infer<typeof UserSchema>;// src/api/getUser.ts
export async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`, { cache: "no-store" });
const json = await res.json();
return UserSchema.parse(json); // throws on mismatch
}Tip: Tolerate additive changes by ignoring extra fields (
.passthrough()), but fail fast on breaking changes.
Adapters: the anti‑corruption layer
Keep server quirks out of your UI by mapping to clean domain models.
// src/adapters/userAdapter.ts
type RawUser = {
id: string;
full_name?: string;
name?: string;
email?: string | null;
tier?: string;
};
export function toUser(raw: RawUser): User {
return {
id: raw.id,
name: raw.full_name ?? raw.name ?? "Unknown",
email: raw.email ?? null,
plan: ((raw.tier === "paid" ? "pro" : raw.tier) as User["plan"]) ?? "free",
};
}Adapters give you one place to cope with renames, enum migrations, or nullability flips.
Schema‑aware components
Design components that accept field descriptors or schema fragments and render accordingly.
// src/components/SmartField.tsx
type FieldDescriptor =
| { kind: "text"; name: string; label: string; required?: boolean }
| { kind: "select"; name: string; label: string; options: Array<{ label: string; value: string }> }
| { kind: "number"; name: string; label: string; min?: number; max?: number };
export function SmartField({ field }: { field: FieldDescriptor }) {
switch (field.kind) {
case "text":
return <label className="block">
<span>{field.label}</span>
<input name={field.name} required={field.required} className="mt-1 w-full rounded border px-3 py-2" />
</label>;
case "number":
return <label className="block">
<span>{field.label}</span>
<input type="number" name={field.name} min={field.min} max={field.max} className="mt-1 w-full rounded border px-3 py-2" />
</label>;
case "select":
return <label className="block">
<span>{field.label}</span>
<select name={field.name} className="mt-1 w-full rounded border px-3 py-2">
{field.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>;
}
}Forms can be driven from JSON Schema/Zod with light mapping; unknown fields fall back to a generic renderer.
Handling versioning, flags, and deprecations
- Version headers: send
Accept: application/vnd.example.v2+jsonor API version in URL. - Feature flags: gate new fields/flows; components read flags to enable progressive enhancement.
- Deprecations: log runtime warnings (dev), document removals, provide codemods where applicable.
Data fetching with TanStack Query
pnpm add @tanstack/react-query zod// src/api/users.ts
import { useQuery } from "@tanstack/react-query";
import { UserSchema, type User } from "../schemas/user";
export function useUser(id: string) {
return useQuery<User>({
queryKey: ["user", id],
queryFn: async () =>
UserSchema.parse(await (await fetch(`/api/users/${id}`)).json()),
staleTime: 60_000,
retry: (failures, error) => failures < 2,
});
}Expose stable query keys and tags so parts of the UI can revalidate selectively.
Error boundaries & resilient UX
- Boundary per route/feature to avoid blank screens.
- Typed errors from Zod (show “data out of date” vs “network down”).
- Fallback partials: show cached/past data with a warning when fresh parse fails.
// app/users/[id]/error.tsx (Next.js)
"use client";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="space-y-2">
<h2 className="text-lg font-semibold">We couldn't show this user</h2>
<pre className="text-sm opacity-70">{error.message}</pre>
<button onClick={reset} className="rounded border px-3 py-2">
Retry
</button>
</div>
);
}Performance: caching, pagination, and streaming
- Favor cursor pagination and infinite queries over massive pages.
- Use select in Query to map/denormalize data before it hits components.
- Stream slow sections with
<Suspense>andloading.tsx. - Memoize adapters; avoid re‑parsing unchanged data.
Testing & CI: schema diffs and contract tests
- Run schema diff on PRs (OpenAPI diff or GraphQL Inspector). Block breaking changes unless versioned.
- Contract tests: mock API with JSON matching the schema; run Zod parse + component render.
- Snapshot adapters with representative payloads (old/new versions).
Reference folders
src/
api/ # fetchers + query hooks
adapters/ # Raw → Domain
components/ # schema-aware UI
schemas/ # zod + generated TS types
pages|app/ # Next.js routes
tests/
contracts/ # schema diff + contract tests
FAQ
Do I need both codegen and Zod?
Yes—types for compile‑time, Zod for runtime. Codegen catches mistakes in your code; Zod catches mistakes in API responses.
What if the backend doesn’t publish OpenAPI/GraphQL?
Start with hand‑authored Zod/JSON Schema and add adapters. Push for contracts later.
Isn’t validation slow?
Validate once per response at the boundary, then cache. For huge lists, validate per item type with array refinements.
Next steps
- Generate types from your API.
- Add Zod parsing at the fetch boundary.
- Introduce adapters and a schema‑driven form component.
- Add schema diffs to CI and ship with confidence.
