| 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:
- Type-safe API contracts (Pydantic → TypeScript OU Prisma → TypeScript native)
- Validation synchronisée (Pydantic ↔ Zod OU Zod + Prisma enums)
- Forms shadcn → Backend (FastAPI routes OU Server Actions)
- Error handling (standardisé backend → UI feedback)
- Loading states (React Query OU useOptimistic)
- Database patterns (Prisma best practices)
Pattern #1: Type-Safe API Contract (Path A - FastAPI)
Backend (Pydantic schemas)
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=, max_length=)
description: | =
status: [, , ] | =
priority: [, , ] | =
():
:
created_at: datetime
updated_at: datetime
user_id:
:
from_attributes =
Frontend (TypeScript types synchronisés)
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 {
}
export interface TaskUpdate {
title?: string
description?: string | null
status?: TaskStatus
priority?: TaskPriority
}
export interface TaskResponse extends TaskBase {
id: string
:
:
:
}
Principe: Types frontend MIRRORED exactement depuis Pydantic pour type-safety.
Pattern #2: Validation Synchronisée (Pydantic ↔ Zod)
Backend Validation (Pydantic)
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É)
import { z } from "zod"
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
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
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)
"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
() {
queryClient = ()
form = useForm<>({
: (taskCreateSchema),
: {
: ,
: ,
: ,
: ,
},
})
mutation = ({
: createTask,
: {
queryClient.({ : [] })
toast.()
form.()
onSuccess?.()
},
: {
toast.(error.)
},
})
= () => {
mutation.(data)
}
(
)
}
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é)
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:
raise HTTPException(
status_code=400,
detail={"message": str(e), "type": "validation_error"}
)
except PermissionError as e:
raise HTTPException(
status_code=403,
detail={"message": str(e), "type": "permission_error"}
)
except Exception as e:
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
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()
}
const mutation = useMutation({
mutationFn: createTask,
onError: (error: APIError) => {
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
"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,
})
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" />
</>
))}
)
}
(error) {
(
)
}
(!data || data. === ) {
(
)
}
(
)
}
Checklist states:
- ✅ Loading (Skeleton UI)
- ✅ Error (Alert destructive)
- ✅ Empty (Message vide)
- ✅ Data (Liste tasks)
Pattern #6: Optimistic Updates
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),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: ["tasks"] })
const previousTasks = queryClient.getQueryData<TaskResponse[]>(["tasks"])
queryClient.setQueryData<TaskResponse[]>(["tasks"], (old) =>
old?.map((task) =>
task. === id ? { ...task, ...data } : task
) || []
)
{ previousTasks }
},
: {
(context?.) {
queryClient.([], context.)
}
toast.()
},
: {
queryClient.({ : [] })
},
})
}
Principe: Update UI immédiatement (optimistic), rollback si erreur serveur.
(Stripe Dashboard pattern)
Checklist Intégration Full-Stack
Backend (FastAPI + Pydantic)
Frontend (React + shadcn + Zod)
Anti-Patterns à Éviter
❌ Validation différente frontend vs backend
z.string().max(100)
Field(..., max_length=200)
✅ Validation synchronisée exactement
❌ Types frontend pas à jour
✅ Types générés ou mirrored manuellement avec checklist
❌ Pas de loading states
{data?.map(...)}
✅ Skeleton UI pendant loading
❌ Errors non gérés
onError: () => {}
✅ 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
- Type-Safety First - Types synchronisés Pydantic ↔ TypeScript
- Validation Mirrored - Frontend validation = Backend validation
- Error Handling Standardisé - Errors structurés backend → Frontend gère
- Loading States Obligatoires - Skeleton UI, pas flash vide
- Optimistic Updates - Meilleure UX, rollback si erreur
- 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/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([userId])
@@index([status])
}
enum TaskStatus {
PENDING
IN_PROGRESS
COMPLETED
}
enum TaskPriority {
LOW
MEDIUM
HIGH
}
Commandes Prisma:
npx prisma generate
npx prisma migrate dev --name add_tasks
npx prisma db push
npx prisma studio
Prisma Client Singleton (Pattern Vercel)
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
Principe: Singleton pour éviter épuisement connexions DB (Vercel best practice)
Next.js API Route avec Prisma (Server Actions)
'use server'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
import { TaskStatus, TaskPriority } from '@prisma/client'
const taskCreateSchema = z.object({
title: z.string().min(1, "Title required").max(200, "Title too long"),
description: z.string().optional(),
status: z.nativeEnum(TaskStatus).default(TaskStatus.PENDING),
priority: z.nativeEnum(TaskPriority).default(TaskPriority.MEDIUM),
userId: z.string().cuid(),
})
export type TaskCreateInput = z.infer<typeof taskCreateSchema>
export () {
{
validated = taskCreateSchema.(input)
task = prisma..({
: validated,
: {
: {
: { : , : , : }
}
}
})
()
{ : , : task }
} (error) {
(error z.) {
{ : , : error.[]. }
}
{ : , : }
}
}
() {
tasks = prisma..({
: { userId },
: {
: {
: { : , : }
}
},
: { : }
})
tasks
}
() {
{
task = prisma..({
: { id },
: { : }
})
(!task || task. !== userId) {
{ : , : }
}
updated = prisma..({
: { id },
data,
})
()
{ : , : updated }
} (error) {
{ : , : }
}
}
() {
{
task = prisma..({
: { id },
: { : }
})
(!task || task. !== userId) {
{ : , : }
}
prisma..({
: { id }
})
()
{ : }
} (error) {
{ : , : }
}
}
Avantages Server Actions:
- ✅ Type-safe natif (Prisma types auto-générés)
- ✅ Pas de route API explicite
- ✅ Revalidation cache Next.js intégrée
- ✅ Streaming support
Frontend avec Server Actions + Prisma Types
import { getTasks } from '@/app/actions/tasks'
import { TaskList } from '@/components/task-list'
import { getCurrentUser } from '@/lib/auth'
export default async function DashboardPage() {
const user = await getCurrentUser()
const tasks = await getTasks(user.id)
return (
<div className="container py-6">
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
<TaskList initialTasks={tasks} userId={user.id} />
</div>
)
}
'use client'
import { useState, useOptimistic } from 'react'
import { Task } from '@prisma/client'
import { deleteTask, updateTask } from '@/app/actions/tasks'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { toast } from 'sonner'
type TaskWithUser = Task & {
user: { id: string; name: string }
}
export function TaskList({
initialTasks,
userId
}: {
initialTasks: TaskWithUser[]
userId: string
}) {
const [tasks, setTasks] = useState(initialTasks)
const [optimisticTasks, addOptimisticTask] = useOptimistic(
tasks,
(state, deletedId: string) => state.filter(t => t.id !== deletedId)
)
const handleDelete = () => {
(taskId)
result = (taskId, userId)
(result.) {
(tasks.( t. !== taskId))
toast.()
} {
(tasks)
toast.(result.)
}
}
= () => {
result = (taskId, userId, { : newStatus })
(result.) {
(tasks.(
t. === taskId ? { ...t, : newStatus } : t
))
toast.()
} {
toast.(result.)
}
}
(
)
}
Prisma Relations & Include Patterns
const task = await prisma.task.findUnique({
where: { id },
include: {
user: true,
}
})
const task = await prisma.task.findUnique({
where: { id },
include: {
user: {
select: { id: true, name: true, email: true }
}
}
})
const user = await prisma.user.findUnique({
where: { id },
include: {
tasks: {
where: { status: 'PENDING' },
orderBy: { createdAt: 'desc' },
take: 10,
}
}
})
const user = await prisma.user.findUnique({
where: { id },
include: {
_count: {
select: { tasks: true }
}
}
})
Prisma Transactions (ACID guarantees)
const result = await prisma.$transaction(async (tx) => {
const task = await tx.task.create({
data: { title: "New task", userId }
})
await tx.user.update({
where: { id: userId },
data: {
tasksCount: { increment: 1 }
}
})
await tx.notification.create({
data: {
userId,
message: `Task "${task.title}" created`
}
})
return task
})
Interactive transactions (complexes):
const result = await prisma.$transaction(
async (tx) => {
const user = await tx.user.findUnique({ where: { id: userId } })
if (!user) throw new Error("User not found")
if (user.tasksCount >= 100) {
throw new Error("Task limit reached")
}
return await tx.task.create({ data: taskData })
},
{
maxWait: 5000,
timeout: 10000,
}
)
Prisma Middleware (Logging, Soft Delete)
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
prisma.$use(async (params, next) => {
const before = Date.now()
const result = await next(params)
const after = Date.now()
console.log(`Query ${params.model}.${params.action} took ${after - before}ms`)
return result
})
prisma.$use(async (params, next) => {
if (params.model === 'Task') {
if (params.action === 'delete') {
params.action = 'update'
params.args['data'] = { deletedAt: new Date() }
}
if (params.action === 'findMany' || params.action === 'findFirst') {
params.args. = { ...params.., : }
}
}
(params)
})
{ prisma }
Pattern #8: Prisma + Zod Full Validation
Generate Zod from Prisma (automatique)
npm install zod-prisma-types
generator zod {
provider = "zod-prisma-types"
output = "../src/lib/zod"
}
npx prisma generate
Résultat auto-généré:
import { z } from 'zod'
export const TaskSchema = z.object({
id: z.string().cuid(),
title: z.string().min(1).max(200),
description: z.string().nullable(),
status: z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED']),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']),
createdAt: z.date(),
updatedAt: z.date(),
userId: z.string().cuid(),
})
export const TaskCreateSchema = TaskSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
})
export const TaskUpdateSchema = TaskCreateSchema.()
Usage dans Server Actions:
import { TaskCreateSchema } from '@/lib/zod'
export async function createTask(input: unknown) {
const validated = TaskCreateSchema.parse(input)
return prisma.task.create({ data: validated })
}
Checklist Prisma + Next.js Integration
Setup
Backend (Server Actions)
Frontend (React)
Performance
Quand utiliser quel Path?
| Path | Stack | Cas d'usage |
|---|
| Path A (FastAPI) | Python + FastAPI + Pydantic + React + shadcn | Backend Python séparé, API REST, microservices, machine learning intégré |
| Path B (Prisma) | Next.js + Prisma + React + shadcn | Full-stack Next.js, Server Actions, déploiement Vercel simplifié |
Recommandation:
- Path A (FastAPI) si besoin backend Python pur ou API séparée
- Path B (Prisma) si stack Next.js full-stack avec Server Actions
Workflow Executor avec Integration Skill + Prisma
User: "Crée CRUD tasks avec Prisma"
Executor:
1. Load skills: frontend + backend + integration
2. Détecte: Prisma mentionné → Prisma pattern
3. Backend (Server Actions):
- Prisma schema (Task model + enums)
- npx prisma migrate dev
- Server actions (create, get, update, delete)
- Zod validation (nativeEnum TaskStatus)
4. Frontend:
- Server Component (getTasks Prisma direct)
- Client Component (Server Actions + useOptimistic)
- Shadcn form
5. Checklist Prisma ✓
Patterns couverts: ✅
- Path A (FastAPI): Pydantic → Zod sync, REST API, React Query
- Path B (Prisma): Prisma native types, Server Actions, useOptimistic
- Prisma best practices (schema, singleton, relations, transactions, middleware)
- Error handling standardisé
- Loading states (Skeleton UI)
- Optimistic updates
Version: 1.2.0
Updated: 2025-01-10 - Nettoyé références tRPC/SQLAlchemy, focus FastAPI + Prisma