Stop Trusting Your API: How to Build a Bulletproof Frontend with Zod and React Query
If you're only using TypeScript interfaces to model API responses, you're one backend change away from a runtime crash—here's how to build a truly resilient app with Zod.
If you’re building modern web apps with React, there’s a good chance you’re using Tanstack (React) Query. It’s an incredible tool for managing server state. The typical workflow is simple: you define a fetch function, create a hook with useQuery
, and your components get the data, loading states, and error states they need.
My code used to look like this:
// in api/accounts.ts
export interface UserProfile {
user_id: string;
email: string;
first_name: string;
// ... and other snake_case properties
}
export async function getUserProfile(): Promise<UserProfile> {
// ...fetches and returns the user profile
}
// in hooks/use-user-profile.ts
export default function useUserProfile() {
return useQuery({
queryKey: ["userProfile"],
queryFn: getUserProfile,
});
}
This works perfectly… until it doesn’t.
The Fragile Contract of TypeScript Interfaces
Let’s be honest. The UserProfile
interface above is based on trust. I’m trusting that the backend API will always send me data that perfectly matches that shape.
But what happens when a backend developer renames user_id
to id
? Or changes email
to be nullable?
My TypeScript compiler won’t complain. My code will build just fine. But at runtime, my app will crash. A component expecting user.first_name
will get undefined
, and the whole thing falls apart. This is because TypeScript interfaces are a compile-time construct; they’re completely erased in the final JavaScript bundle. They offer no runtime protection.
This is where I decided to change my approach by combining an old architectural pattern (DTOs) with a fantastic modern tool (Zod).
Enter Zod: Your App’s Runtime Bodyguard
A Data Transfer Object (DTO) is basically a dedicated model for the data that moves between your app and an external source, like an API. It acts as a translator or an “anti-corruption layer,” protecting your application’s internal logic from the whims of the outside world.
Instead of just trusting the API’s shape with a TypeScript interface
, we can actively verify it at runtime. This is where Zod shines.
With Zod, we can define a schema that acts as a single source of truth for:
- The data’s shape and validation rules.
- A runtime parser that ensures the API data conforms to that shape.
- The inferred TypeScript type, so we don’t have to maintain a separate
interface
.
The Step-by-Step Guide to Bulletproof Data Fetching
Here’s how I now structure my data fetching logic. It’s a small change in code with a massive impact on resilience.
Step 1: Define Your Schemas with Zod
First, I create a schema file for my feature. This file will define two things: the shape of the raw API data and the shape of the clean DTO I want to use in my app.
The magic here is Zod’s .transform()
method, which lets me validate and map the data in one go.
import { z } from "zod";
// 1. A schema for the raw API data (snake_case)
const ApiUserProfileSchema = z.object({
user_id: z.string().uuid("Invalid user ID format"),
email: z.string().email(),
first_name: z.string(),
last_name: z.string(),
company_name: z.string(),
phone_number: z.string(),
updated_at: z.string().datetime(),
role: z.enum(["supplier", "user"]),
});
// 2. A schema that VALIDATES the API data, then TRANSFORMS it into our DTO
export const UserProfileSchema = ApiUserProfileSchema.transform((data) => ({
userId: data.user_id,
email: data.email,
firstName: data.first_name,
lastName: data.last_name,
companyName: data.company_name,
phoneNumber: data.phone_number,
updatedAt: data.updated_at,
role: data.role,
}));
// 3. Infer the TypeScript type for our DTO from the Zod schema
export type UserProfile = z.infer<typeof UserProfileSchema>;
Look at what we just did! We now have a schema that validates the incoming snake_case
data and automatically transforms it into the camelCase
DTO my frontend components will use. Plus, the UserProfile
type is derived automatically—no more manually keeping an interface
in sync!
Step 2: Fortify Your API Function
Next, I update the API query function. Its job is now to fetch the data and immediately pass it through our Zod schema’s parser.
import { httpClient } from "../lib/http";
import { UserProfileSchema, UserProfile } from "../models/user-profile.schema";
export async function getUserProfile(): Promise<UserProfile> {
const data = await httpClient(
`${import.meta.env.VITE_API_BASE_URL}/accounts/profile`
);
// This is the key!
// .parse() validates and transforms the data.
// If validation fails, it throws an error that React Query will catch.
return UserProfileSchema.parse(data);
}
The UserProfileSchema.parse(data)
line is the critical piece. If the data from the API doesn’t match the schema, Zod will throw a beautifully detailed error. Tanstack Query will catch this error and correctly put our query into an error
state, preventing malformed data from ever reaching our components.
Step 3: Your Hook and Components Remain Unchanged
This is the best part. All this newfound safety and resilience is completely encapsulated. My useUserProfile
hook doesn’t need to change at all.
import { useQuery } from "@tanstack/react-query";
import { getUserProfile } from "../api/accounts";
export default function useUserProfile() {
return useQuery({
queryKey: ["userProfile"],
queryFn: getUserProfile,
});
}
The data
returned by this hook is now guaranteed to be safe. It’s been validated, transformed, and its type is perfectly aligned with our frontend DTO.
Why This is a Game-Changer
By adopting this pattern, I’ve made my application fundamentally more robust.
- Resilience: My app is protected from unexpected API changes.
- Clarity: I have a clear, defined boundary between the “outside world” (the API) and my application’s internal domain.
- Developer Experience: A single Zod schema provides validation, transformation, and a TypeScript type. It’s a single source of truth that’s easy to maintain.
It’s a small investment in boilerplate for a huge return in stability and peace of mind. Give it a try on your next feature—I promise you won’t look back.
Happy coding
💡 Need a Developer Who Gets It Done?
If this post helped solve your problem, imagine what we could build together! I'm a full-stack developer with expertise in Python, Django, Typescript, and modern web technologies. I specialize in turning complex ideas into clean, efficient solutions.