Your FastAPI backend defines data shapes with Pydantic models. Your React frontend sends and receives that data. If they disagree on the shape, things break — often silently.
Copy
# Backend expects this shapeclass UserCreate(BaseModel): name: str email: str
Copy
// Frontend sends this — missing "name", has extra "username"const userData = { username: "Sarah", email: "sarah@example.com" };await createUser(userData);// Backend gets { username: "Sarah", email: "..." }// "name" is missing → 422 Validation Error// "username" is ignored silently
The backend rejects it with a 422 error, but the error message isn’t always obvious. Or worse — the frontend reads a field that the backend renamed, and you get undefined rendering on screen with no error at all.
function validateUserData(data) { const errors = {}; if (!data.name || data.name.trim() === "") { errors.name = "Name is required"; } if (!data.email || !data.email.includes("@")) { errors.email = "Valid email is required"; } return { isValid: Object.keys(errors).length === 0, errors, };}// Use before API callfunction handleSubmit(formData) { const { isValid, errors } = validateUserData(formData); if (!isValid) { setErrors(errors); return; } createUser(formData); // Only sends if valid}
Validate on the frontend for instant user feedback. But always validate on the backend too — frontend validation can be bypassed. Pydantic handles backend validation automatically. Think of frontend validation as a UX feature, not a security feature.
Design your backend responses to be predictable. When every endpoint follows the same pattern, the frontend code becomes simpler.
Copy
# Backend — consistent patterns# List endpoint always returns an array@app.get("/api/users")def get_users(): return users # Always returns []# Single item returns the object or 404@app.get("/api/users/{user_id}")def get_user(user_id: int): user = find_user(user_id) if not user: raise HTTPException(status_code=404, detail="User not found") return user# Create returns the created object with 201@app.post("/api/users", status_code=201)def create_user(user: UserCreate): new_user = save_user(user) return new_user # Returns the full object with id# Update returns the updated object@app.put("/api/users/{user_id}")def update_user(user_id: int, user: UserCreate): updated = update_user_in_db(user_id, user) return updated# Delete returns nothing with 204@app.delete("/api/users/{user_id}", status_code=204)def delete_user(user_id: int): remove_user(user_id)
The frontend can rely on these patterns:
GET list → always an array (even if empty)
GET single → the object, or a 404 error
POST → returns the created object (with its new id)
PUT → returns the updated object
DELETE → returns nothing (204 status)
The companion repo keeps the backend simple and uses UserCreate for both create and update operations. A separate UserUpdate model (with optional fields) is a common next step once you want partial updates.
FastAPI returns 422 errors with a structured format. Parse them in your API client:
Copy
// frontend/src/api/client.jsexport async function apiClient(endpoint, options = {}) { const response = await fetch(`${API_URL}${endpoint}`, { headers: { "Content-Type": "application/json", ...options.headers }, ...options, }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); // FastAPI 422 validation errors have a specific shape if (response.status === 422 && errorData.detail) { // Convert FastAPI's validation format to a simple object const fieldErrors = {}; for (const err of errorData.detail) { const field = err.loc[err.loc.length - 1]; // Last item is the field name fieldErrors[field] = err.msg; } const error = new Error("Validation failed"); error.fieldErrors = fieldErrors; throw error; } throw new Error(errorData.detail || `HTTP ${response.status}`); } if (response.status === 204) return null; return response.json();}
Copy
// In your componentasync function handleSubmit(formData) { try { await createUser(formData); } catch (err) { if (err.fieldErrors) { setErrors(err.fieldErrors); // Show per-field errors } else { setError(err.message); // Show general error } }}
FastAPI validation errors return an array of { loc: ["body", "email"], msg: "field required", type: "missing" } objects. The code above extracts the field name and message into a simple { email: "field required" } format that your form components can display directly.
If you want true compile-time type safety, TypeScript is the answer. Here’s a taste:
Copy
// With TypeScript — errors caught before you even run the codeinterface User { id: number; name: string; email: string;}interface UserCreate { name: string; email: string;}export async function createUser(data: UserCreate): Promise<User> { return apiClient("/api/users", { method: "POST", body: JSON.stringify(data), });}// ❌ TypeScript error: Property 'username' does not exist on type 'UserCreate'createUser({ username: "Sarah", email: "sarah@example.com" });
TypeScript catches the username vs name mismatch at development time, before any code runs. This is the gold standard for full-stack type safety.
You don’t need TypeScript to build full-stack apps. JavaScript with good documentation and runtime validation works fine, especially when you’re learning. But once you’re comfortable with JavaScript, TypeScript is a natural next step — it catches an entire category of bugs automatically.
Your frontend and backend are connected, organized, and type-aware. Now let’s build the four CRUD operations that make up every data-driven application — starting with Create.