React 19 Form Actions with FastAPI: Server-Side Validation That Hydrates Client State Without Duplicate Logic
Form validation is where I watch developers waste the most time. You write a schema in Pydantic. You duplicate it in Zod on the frontend. You write error messages twice. You wire up loading states manually. You debug why the client and server disagree about what’s valid.
I’ve shipped enough forms to know: this is insane.
React 19’s form actions paired with FastAPI give you a path out. Your validation lives once—on the server. Your form component receives structured errors and auto-populates without fetching. Your loading state is built-in. Your action handler runs server-side, so sensitive operations never touch the browser.
This is the pattern I use in CitizenApp, and it’s cut our form code by roughly half.
The Old Way (What You’re Probably Doing)
Before I show you the good stuff, let me paint the wasteful picture:
- Pydantic schema defines validation rules
- Zod schema duplicates those rules on the frontend
- REST endpoint returns validation errors
- useState wires up error messages manually
- useTransition handles loading state
- useCallback manages submission
That’s five places where form logic lives. When you add a validation rule, you update at least three of them.
Worse: if your client-side validation passes but the server rejects it, your UX breaks. Users see a spinner, then a generic error, then they don’t know what to fix.
React 19 Form Actions + FastAPI: The Pattern
React 19 gives you useActionState. It’s built for server actions—but you can bend it toward FastAPI.
Here’s how it works:
- Form submits to a server action (a function marked
'use server') - That function calls your FastAPI endpoint
- FastAPI returns structured validation errors
- React automatically re-renders with those errors populated
- No manual state management, no duplicate validation logic
Let me show you the code.
Step 1: Define Your FastAPI Schema (Single Source of Truth)
# backend/schemas.py
from pydantic import BaseModel, EmailStr, Field
class CreateUserRequest(BaseModel):
email: EmailStr
name: str = Field(..., min_length=2, max_length=100)
password: str = Field(..., min_length=12)
class ValidationError(BaseModel):
field: str
message: str
class CreateUserResponse(BaseModel):
success: bool
user_id: str | None = None
errors: list[ValidationError] = []
Notice: I’m not writing validation rules anywhere else. Pydantic handles it all.
Step 2: Write Your FastAPI Endpoint
# backend/main.py
from fastapi import FastAPI, HTTPException
from pydantic import ValidationError as PydanticValidationError
import httpx
app = FastAPI()
@app.post("/api/users")
async def create_user(req: CreateUserRequest):
"""
FastAPI automatically validates the request body against CreateUserRequest.
If validation fails, it returns a 422 with structured errors.
"""
# Your business logic here
# (check if email exists, hash password, etc.)
try:
user = await db.users.create(
email=req.email,
name=req.name,
password_hash=hash_password(req.password)
)
return CreateUserResponse(success=True, user_id=user.id)
except Exception as e:
return CreateUserResponse(
success=False,
errors=[
ValidationError(
field="email",
message="This email is already registered"
)
]
)
Key point: FastAPI handles validation. If the request body doesn’t match the schema, it responds with 422 and a detailed error. We just need to catch it on the client.
Step 3: Create a Server Action That Calls FastAPI
This is where it gets interesting. Your Next.js (or Astro with a Node adapter) server action becomes a thin bridge:
// app/actions/create-user.ts
'use server'
import { CreateUserRequest, CreateUserResponse, ValidationError } from '@/types'
export async function createUserAction(
_prevState: CreateUserResponse | null,
formData: FormData
): Promise<CreateUserResponse> {
const email = formData.get('email')
const name = formData.get('name')
const password = formData.get('password')
const req: CreateUserRequest = {
email: email as string,
name: name as string,
password: password as string,
}
try {
const response = await fetch(
`${process.env.FASTAPI_URL}/api/users`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
}
)
if (!response.ok) {
// FastAPI returns 422 with Pydantic errors
const errorData = await response.json()
// Transform Pydantic's error format into our schema
const errors: ValidationError[] = (errorData.detail || []).map(
(err: any) => ({
field: err.loc[1] || 'unknown', // Pydantic error format
message: err.msg,
})
)
return { success: false, errors, user_id: null }
}
return await response.json()
} catch (error) {
return {
success: false,
user_id: null,
errors: [{ field: 'form', message: 'Network error. Try again.' }],
}
}
}
Step 4: Wire It Up in React with useActionState
// app/components/SignupForm.tsx
'use client'
import { useActionState } from 'react'
import { createUserAction } from '@/app/actions/create-user'
export function SignupForm() {
const [state, formAction, isPending] = useActionState(
createUserAction,
null
)
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
/>
{state?.errors
?.filter((e) => e.field === 'email')
.map((error) => (
<p key={error.field} className="mt-1 text-sm text-red-600">
{error.message}
</p>
))}
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium">
Full Name
</label>
<input
id="name"
name="name"
type="text"
required
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
/>
{state?.errors
?.filter((e) => e.field === 'name')
.map((error) => (
<p key={error.field} className="mt-1 text-sm text-red-600">
{error.message}
</p>
))}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
/>
{state?.errors
?.filter((e) => e.field === 'password')
.map((error) => (
<p key={error.field} className="mt-1 text-sm text-red-600">
{error.message}
</p>
))}
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? 'Creating...' : 'Sign Up'}
</button>
{state?.success && (
<p className="text-sm text-green-600">
Account created! User ID: {state.user_id}
</p>
)}
</form>
)
}
Why This Pattern Works
Single source of truth: Pydantic schema is authoritative. Change it once, and FastAPI validates automatically.
No client-side validation library: You don’t need Zod, React Hook Form, or custom validation. The server tells you what’s wrong.
Type safety without duplication: Your TypeScript types mirror Pydantic. Generate them once with tools like datamodel-code-generator.
Built-in loading state: useActionState gives you isPending for free. No useState needed.
Structured errors: You get field-level errors, not a generic “validation failed” message. Users know exactly what to fix.
Gotcha: Pydantic Error Format
FastAPI’s 422 response doesn’t match a clean schema—it returns detail with Pydantic’s internal error format:
{
"detail": [
{
"type": "value_error.email",
"loc": ["body", "email"],
"msg": "invalid email format",
"input": "notanemail"
}
]
}
I spent an hour debugging why my error transform wasn’t working until I realized loc is a tuple, and the field name is at index 1, not 0. The code above handles it, but it’s worth understanding why.
Also: if you need custom validation messages, Pydantic’s `Field(description