Back to catalog

integration

Backend-Frontend integration patterns expert. Type-safe API contracts with Pydantic-Zod validation sync (Python FastAPI) or Prisma-TypeScript native (Next.js). Shadcn forms connected to backend, error handling, loading states. Use when creating full-stack features.

  • Category: discovered
  • Tags: github, community
  • Version: 1.0.0
  • Author: walid
  • Downloads: 0
  • Updated: 2025-11-22

Download the latest version (v1.0.0) from this page.

SKILL.md

---
name: integration
description: Backend-Frontend integration patterns expert. Type-safe API contracts with Pydantic-Zod validation sync (Python FastAPI) or Prisma-TypeScript native (Next.js). Shadcn forms connected to backend, error handling, loading states. Use when creating full-stack features.
allowed-tools: Read, Write, Edit, Glob, Grep
---

# Integration Skill - Backend ↔ Frontend Patterns

> **Expert intégration backend (Python FastAPI ou Next.js) ↔ frontend React + shadcn**
>
> Inspiré de : Stripe API patterns, Vercel full-stack architecture, Prisma best practices

---

## Scope

**Chargé par:** executor agent (quand feature full-stack détectée)

**Deux chemins d'intégration:**

### Path A: Python FastAPI + React + shadcn (REST API)
- Backend: Python + FastAPI + Pydantic
- Frontend: React + Next.js + shadcn
- Communication: REST API (JSON)
- Validation: Pydantic backend ↔ Zod frontend (mirrored)

### Path B: Next.js Full-Stack + React + shadcn (Server Actions)
- Backend: Next.js Server Actions + Prisma
- Frontend: React + Next.js + shadcn
- Communication: Server Actions (RPC-like)
- Validation: Zod + Prisma native types

**Patterns couverts:**
1. **Type-safe API contracts** (Pydantic → TypeScript OU Prisma → TypeScript native)
2. **Validation synchronisée** (Pydantic ↔ Zod OU Zod + Prisma enums)
3. **Forms shadcn → Backend** (FastAPI routes OU Server Actions)
4. **Error handling** (standardisé backend → UI feedback)
5. **Loading states** (React Query OU useOptimistic)
6. **Database patterns** (Prisma best practices)

---

## Pattern #1: Type-Safe API Contract (Path A - FastAPI)

### Backend (Pydantic schemas)

```python
# backend/app/schemas/task.py
from pydantic import BaseModel, Field, validator
from datetime import datetime
from typing import Literal

class TaskBase(BaseModel):
    title: str = Field(..., min_length=1, max_length=200)
    description: str | None = None
    status: Literal["pending", "in_progress", "completed"] = "pending"
    priority: Literal["low", "medium", "high"] = "medium"

    @validator("title")
    def title_not_empty(cls, v):
        if not v.strip():
            raise ValueError("Title cannot be empty")
        return v.strip()

class TaskCreate(TaskBase):
    """Input creation (pas d'ID)"""
    pass

class TaskUpdate(BaseModel):
    """Update partiel (tous optionnels)"""
    title: str | None = Field(None, min_length=1, max_length=200)
    description: str | None = None
    status: Literal["pending", "in_progress", "completed"] | None = None
    priority: Literal["low", "medium", "high"] | None = None

class TaskResponse(TaskBase):
    """Output avec metadata DB"""
    id: str
    created_at: datetime
    updated_at: datetime
    user_id: str

    class Config:
        from_attributes = True
```

### Frontend (TypeScript types synchronisés)

```typescript
// frontend/src/types/task.ts

// Mirror exact Pydantic schemas
export type TaskStatus = "pending" | "in_progress" | "completed"
export type TaskPriority = "low" | "medium" | "high"

export interface TaskBase {
  title: string
  description?: string | null
  status: TaskStatus
  priority: TaskPriority
}

export interface TaskCreate extends TaskBase {
  // Pas d'ID pour création
}

export interface TaskUpdate {
  // Tous optionnels pour update partiel
  title?: string
  description?: string | null
  status?: TaskStatus
  priority?: TaskPriority
}

export interface TaskResponse extends TaskBase {
  id: string
  created_at: string  // ISO string du datetime Python
  updated_at: string
  user_id: string
}
```

**Principe:** Types frontend **MIRRORED** exactement depuis Pydantic pour type-safety.

---

## Pattern #2: Validation Synchronisée (Pydantic ↔ Zod)

### Backend Validation (Pydantic)

```python
# backend/app/schemas/task.py
class TaskCreate(BaseModel):
    title: str = Field(..., min_length=1, max_length=200)
    description: str | None = None

    @validator("title")
    def title_not_empty(cls, v):
        if not v.strip():
            raise ValueError("Title cannot be empty")
        return v.strip()
```

### Frontend Validation (Zod - SYNCHRONISÉ)

```typescript
// frontend/src/schemas/task.ts
import { z } from "zod"

// Mirror EXACT validation Pydantic
export const taskCreateSchema = z.object({
  title: z.string()
    .min(1, "Title cannot be empty")
    .max(200, "Title too long")
    .refine(val => val.trim().length > 0, "Title cannot be empty"),
  description: z.string().optional().nullable(),
  status: z.enum(["pending", "in_progress", "completed"]).default("pending"),
  priority: z.enum(["low", "medium", "high"]).default("medium"),
})

export type TaskCreateInput = z.infer<typeof taskCreateSchema>
```

**RÈGLE CRITIQUE:** Validation frontend **identique** backend.
- Même min/max lengths
- Même regex patterns
- Même error messages (traduits si nécessaire)

**Principe:** Frontend validation = UX rapide, Backend validation = sécurité.
(Defense in depth - jamais trust client)

---

## Pattern #3: Shadcn Form → FastAPI Complete

### Backend API Route

```python
# backend/app/api/routes/tasks.py
from fastapi import APIRouter, HTTPException, Depends
from app.schemas.task import TaskCreate, TaskResponse
from app.services.task_service import task_service

router = APIRouter(prefix="/tasks", tags=["tasks"])

@router.post("/", response_model=TaskResponse, status_code=201)
async def create_task(
    task: TaskCreate,
    user_id: str = Depends(get_current_user)
):
    """Crée nouvelle task avec validation"""
    try:
        return task_service.create_task(user_id, task)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail="Internal server error")
```

### Frontend API Client

```typescript
// frontend/src/lib/api/tasks.ts
import { TaskCreate, TaskResponse } from "@/types/task"

const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"

export async function createTask(task: TaskCreate): Promise<TaskResponse> {
  const response = await fetch(`${API_BASE}/api/tasks`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${getToken()}`,
    },
    body: JSON.stringify(task),
  })

  if (!response.ok) {
    const error = await response.json()
    throw new Error(error.detail || "Failed to create task")
  }

  return response.json()
}
```

### Shadcn Form Component (Complete)

```tsx
// frontend/src/components/task-form.tsx
"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

import { taskCreateSchema, type TaskCreateInput } from "@/schemas/task"
import { createTask } from "@/lib/api/tasks"

export function TaskForm({ onSuccess }: { onSuccess?: () => void }) {
  const queryClient = useQueryClient()

  // Form avec Zod validation
  const form = useForm<TaskCreateInput>({
    resolver: zodResolver(taskCreateSchema),
    defaultValues: {
      title: "",
      description: "",
      status: "pending",
      priority: "medium",
    },
  })

  // Mutation avec React Query
  const mutation = useMutation({
    mutationFn: createTask,
    onSuccess: () => {
      // Invalidate cache pour refresh liste
      queryClient.invalidateQueries({ queryKey: ["tasks"] })
      toast.success("Task created successfully")
      form.reset()
      onSuccess?.()
    },
    onError: (error: Error) => {
      toast.error(error.message)
    },
  })

  const onSubmit = (data: TaskCreateInput) => {
    mutation.mutate(data)
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        {/* Title */}
        <FormField
          control={form.control}
          name="title"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Title</FormLabel>
              <FormControl>
                <Input placeholder="Enter task title" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Description */}
        <FormField
          control={form.control}
          name="description"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Description</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="Enter task description"
                  {...field}
                  value={field.value || ""}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Priority */}
        <FormField
          control={form.control}
          name="priority"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Priority</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="Select priority" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="low">Low</SelectItem>
                  <SelectItem value="medium">Medium</SelectItem>
                  <SelectItem value="high">High</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Submit */}
        <Button type="submit" disabled={mutation.isPending}>
          {mutation.isPending ? "Creating..." : "Create Task"}
        </Button>
      </form>
    </Form>
  )
}
```

**Checklist connexion complète:**
- ✅ Zod schema mirrored depuis Pydantic
- ✅ TypeScript types synchronisés
- ✅ React Hook Form + Zod resolver
- ✅ React Query mutation (loading + error states)
- ✅ Toast notifications (success/error)
- ✅ Cache invalidation (refresh après create)
- ✅ Form reset après success

---

## Pattern #4: Error Handling (Backend → Frontend)

### Backend Error Responses (standardisé)

```python
# backend/app/api/routes/tasks.py
from fastapi import HTTPException
from pydantic import ValidationError

@router.post("/")
async def create_task(task: TaskCreate):
    try:
        return task_service.create_task(task)

    except ValueError as e:
        # Business logic error
        raise HTTPException(
            status_code=400,
            detail={"message": str(e), "type": "validation_error"}
        )

    except PermissionError as e:
        # Authorization error
        raise HTTPException(
            status_code=403,
            detail={"message": str(e), "type": "permission_error"}
        )

    except Exception as e:
        # Unexpected error (log + generic message)
        logger.error(f"Unexpected error: {e}", exc_info=True)
        raise HTTPException(
            status_code=500,
            detail={"message": "Internal server error", "type": "server_error"}
        )
```

### Frontend Error Handling

```typescript
// frontend/src/lib/api/client.ts
export class APIError extends Error {
  constructor(
    message: string,
    public status: number,
    public type?: string
  ) {
    super(message)
  }
}

export async function apiClient<T>(
  url: string,
  options?: RequestInit
): Promise<T> {
  const response = await fetch(url, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      ...options?.headers,
    },
  })

  if (!response.ok) {
    const error = await response.json().catch(() => ({}))
    throw new APIError(
      error.detail?.message || error.detail || "Request failed",
      response.status,
      error.detail?.type
    )
  }

  return response.json()
}
```

```tsx
// frontend/src/components/task-form.tsx
const mutation = useMutation({
  mutationFn: createTask,
  onError: (error: APIError) => {
    // Error handling basé sur type
    switch (error.type) {
      case "validation_error":
        toast.error(`Validation error: ${error.message}`)
        break
      case "permission_error":
        toast.error("You don't have permission to perform this action")
        break
      case "server_error":
        toast.error("Server error. Please try again later.")
        break
      default:
        toast.error(error.message)
    }
  },
})
```

**Principe:** Errors backend standardisés avec types → Frontend gère selon type.
(Inspiration: Stripe API errors)

---

## Pattern #5: Loading States (React Query)

### Liste avec Loading/Error/Empty States

```tsx
// frontend/src/components/task-list.tsx
"use client"

import { useQuery } from "@tanstack/react-query"
import { Card } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { getTasks } from "@/lib/api/tasks"

export function TaskList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ["tasks"],
    queryFn: getTasks,
  })

  // Loading state
  if (isLoading) {
    return (
      <div className="space-y-4">
        {[...Array(3)].map((_, i) => (
          <Card key={i} className="p-4">
            <Skeleton className="h-6 w-3/4 mb-2" />
            <Skeleton className="h-4 w-full" />
          </Card>
        ))}
      </div>
    )
  }

  // Error state
  if (error) {
    return (
      <Alert variant="destructive">
        <AlertDescription>
          Failed to load tasks: {error.message}
        </AlertDescription>
      </Alert>
    )
  }

  // Empty state
  if (!data || data.length === 0) {
    return (
      <div className="text-center p-8 text-muted-foreground">
        No tasks yet. Create your first task!
      </div>
    )
  }

  // Data state
  return (
    <div className="space-y-4">
      {data.map((task) => (
        <Card key={task.id} className="p-4">
          <h3 className="font-semibold">{task.title}</h3>
          <p className="text-sm text-muted-foreground">{task.description}</p>
        </Card>
      ))}
    </div>
  )
}
```

**Checklist states:**
- ✅ Loading (Skeleton UI)
- ✅ Error (Alert destructive)
- ✅ Empty (Message vide)
- ✅ Data (Liste tasks)

---

## Pattern #6: Optimistic Updates

```tsx
// frontend/src/hooks/useTasks.ts
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { updateTask } from "@/lib/api/tasks"
import type { TaskResponse, TaskUpdate } from "@/types/task"

export function useUpdateTask() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: TaskUpdate }) =>
      updateTask(id, data),

    // Optimistic update AVANT requête serveur
    onMutate: async ({ id, data }) => {
      // Cancel outgoing queries
      await queryClient.cancelQueries({ queryKey: ["tasks"] })

      // Snapshot previous value
      const previousTasks = queryClient.getQueryData<TaskResponse[]>(["tasks"])

      // Optimistically update cache
      queryClient.setQueryData<TaskResponse[]>(["tasks"], (old) =>
        old?.map((task) =>
          task.id === id ? { ...task, ...data } : task
        ) || []
      )

      // Return context avec previous value
      return { previousTasks }
    },

    // Si erreur → Rollback
    onError: (err, variables, context) => {
      if (context?.previousTasks) {
        queryClient.setQueryData(["tasks"], context.previousTasks)
      }
      toast.error("Failed to update task")
    },

    // Après success → Refetch pour sync
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["tasks"] })
    },
  })
}
```

**Principe:** Update UI immédiatement (optimistic), rollback si erreur serveur.
(Stripe Dashboard pattern)

---

## Checklist Intégration Full-Stack

### Backend (FastAPI + Pydantic)
- [ ] Pydantic schemas définis (Base, Create, Update, Response)
- [ ] Validation Pydantic complète (min/max, regex, custom validators)
- [ ] API routes avec error handling standardisé
- [ ] Services layer (business logic séparée)
- [ ] Error responses structurées (message + type)

### Frontend (React + shadcn + Zod)
- [ ] TypeScript types mirrored depuis Pydantic
- [ ] Zod schemas synchronisés avec Pydantic validation
- [ ] API client avec error handling
- [ ] React Query setup (mutations + queries)
- [ ] Shadcn forms avec react-hook-form + Zod resolver
- [ ] Loading states (Skeleton UI)
- [ ] Error states (Alert components)
- [ ] Empty states (messages appropriés)
- [ ] Success feedback (toast notifications)
- [ ] Cache invalidation (après mutations)
- [ ] Optimistic updates (si applicable)

---

## Anti-Patterns à Éviter

❌ **Validation différente frontend vs backend**
```typescript
// Frontend: max 100 chars
z.string().max(100)

// Backend: max 200 chars
Field(..., max_length=200)

// → Incohérence = bugs
```

✅ **Validation synchronisée exactement**

---

❌ **Types frontend pas à jour**
```typescript
// Backend ajouté field "priority"
// Frontend oublie → TypeScript errors partout
```

✅ **Types générés ou mirrored manuellement avec checklist**

---

❌ **Pas de loading states**
```tsx
{data?.map(...)}  // Pas de loading → Flash vide
```

✅ **Skeleton UI pendant loading**

---

❌ **Errors non gérés**
```tsx
onError: () => {}  // Error silencieux = mauvaise UX
```

✅ **Toast + error messages clairs**

---

## Workflow Executor avec Integration Skill

**Quand executor crée feature full-stack:**

```
1. executor détecte: feature nécessite backend + frontend
2. Load skills: backend + frontend + integration
3. Phase A - Backend:
   - Crée Pydantic schemas (selon integration patterns)
   - Crée API route avec error handling standardisé
   - Crée service layer
4. Phase B - Frontend:
   - Crée TypeScript types (mirrored Pydantic)
   - Crée Zod schemas (synchronized validation)
   - Crée API client
   - Crée shadcn form (complete pattern)
   - Loading/Error/Empty states
5. Validation:
   - Checklist intégration complète
   - Types synchronisés ✓
   - Validation identique ✓
   - Error handling ✓
```

---

## Principes

1. **Type-Safety First** - Types synchronisés Pydantic ↔ TypeScript
2. **Validation Mirrored** - Frontend validation = Backend validation
3. **Error Handling Standardisé** - Errors structurés backend → Frontend gère
4. **Loading States Obligatoires** - Skeleton UI, pas flash vide
5. **Optimistic Updates** - Meilleure UX, rollback si erreur
6. **Defense in Depth** - Validation frontend (UX) + backend (sécurité)

**Inspiré de:**
- Stripe Dashboard (error handling + loading states + optimistic updates)
- Vercel (full-stack TypeScript patterns)

---

**Version**: 1.0.0
**Last updated**: 2025-01-10
**Maintained by**: executor agent (loaded pour features full-stack)

---

## Pattern #7: Prisma + Next.js Full-Stack (Path B - Type-Safe Native)

### Database Schema (Prisma)

```prisma
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  tasks     Task[]

  @@index([email])
}

model Task {
  id          String   @id @default(cuid())
  title       String
  description String?
  status      TaskStatus @default(PENDING)
  priority    TaskPriority @default(MEDIUM)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId      String

  @@index([u