Skip to main content
All posts

React 19 Form Actions with FastAPI: Server-Side Validation That

7 June 2026

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:

  1. Pydantic schema defines validation rules
  2. Zod schema duplicates those rules on the frontend
  3. REST endpoint returns validation errors
  4. useState wires up error messages manually
  5. useTransition handles loading state
  6. 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:

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

You might also like

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch