agentleFS
thomasetiennethomasetienne/invision-ai/.cursor/rules/frontend_development.mdc

invision-ai / rules

Cursor rule1 starsChanged 10 months agoMITView on GitHub

Guide complet pour le développement frontend T3 : tRPC React, Server/Client Components, shadcn/ui, nuqs, state management et bonnes pratiques.

Files
frontend_development.mdc
---
description: "Guide complet pour le développement frontend T3 : tRPC React, Server/Client Components, shadcn/ui, nuqs, state management et bonnes pratiques."
globs:
  - "src/app/**/*.{ts,tsx}"
  - "src/components/**/*.{ts,tsx}"
  - "src/hooks/**/*.{ts,tsx}"
  - "src/stores/**/*.{ts,tsx}"
  - "**/*.css"
  - "package.json"
  - "next.config.*"
  - "tailwind.config.*"
  - "tsconfig.json"
alwaysApply: false
---

# Guide Frontend T3 Stack

## 🚨 RÈGLE ABSOLUE - LIRE EN PREMIER

> **⚠️ OBLIGATOIRE** : Consultez `components_usage_policy.mdc` avant tout développement
> 
> **INTERDICTIONS STRICTES** :
> - ❌ JAMAIS de HTML brut pour les éléments UI
> - ❌ JAMAIS de réinvention de composants
> - ❌ JAMAIS de création sans vérifier `src/components/ui/` d'abord
> 
> **OBLIGATOIRE** :
> - ✅ TOUJOURS utiliser les composants existants (60+ disponibles)
> - ✅ TOUJOURS consulter `ui_components_reference.mdc` avant de créer
> - ✅ TOUJOURS demander avant de créer un nouveau composant

## Stack Technique

- **Framework** : Next.js 15+ avec App Router
- **API** : tRPC React (type-safe)
- **Styling** : Tailwind CSS + shadcn/ui
- **State** : Zustand (UI) + Nuqs (query params) + tRPC React Query (server state)
- **Formulaires** : React Hook Form + Zod
- **i18n** : next-intl (internationalisation)
- **Dates** : date-fns (formatage de dates)
- **Icons** : Lucide React
- **Composants UI** : 60+ composants de haute qualité dans `src/components/ui/`

---

## 1. Architecture Server/Client Components

### Principe Fondamental
- **Server Components** : Logique métier, accès données via tRPC, calculs
- **Client Components** : UI interactive, événements, state local

### Quand utiliser Server Components
```typescript
// ✅ Server Component (par défaut)
// src/app/dashboard/page.tsx
import { api, HydrateClient } from "~/trpc/server";

export default async function DashboardPage() {
  // Appel tRPC côté serveur
  const posts = await api.post.getAll({
    page: 1,
    pageSize: 20,
  });

  // Précharger pour le client
  void api.post.getLatest.prefetch();

  return (
    <HydrateClient>
      <DashboardView initialPosts={posts.posts} />
    </HydrateClient>
  );
}
```

### Quand utiliser Client Components
```typescript
// ✅ Client Component - UI interactive
// src/components/features/dashboard/DashboardView.tsx
"use client";

import { useState } from "react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";

interface DashboardViewProps {
  initialPosts: Post[];
}

export function DashboardView({ initialPosts }: DashboardViewProps) {
  const [isModalOpen, setIsModalOpen] = useState(false);
  
  // Utiliser les données préchargées
  const { data: posts } = api.post.getAll.useQuery(
    { page: 1, pageSize: 20 },
    { initialData: { posts: initialPosts, pagination: {} } }
  );

  return (
    <div>
      <Button onClick={() => setIsModalOpen(true)}>
        Ajouter un post
      </Button>
      {/* UI interactive */}
    </div>
  );
}
```

---

## 2. tRPC React - Utilisation

### Hooks tRPC
```typescript
// src/components/features/posts/PostsList.tsx
"use client";

import { api } from "~/trpc/react";
import { PostCard } from "./PostCard";

export function PostsList() {
  // Query avec pagination
  const { data, isLoading, error } = api.post.getAll.useQuery({
    page: 1,
    pageSize: 20,
    search: "",
  });

  if (isLoading) return <div>Chargement...</div>;
  if (error) return <div>Erreur: {error.message}</div>;

  return (
    <div className="grid gap-4">
      {data?.posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}
```

### Mutations tRPC
```typescript
// src/components/features/posts/CreatePostForm.tsx
"use client";

import { useState } from "react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";

export function CreatePostForm() {
  const [name, setName] = useState("");
  const utils = api.useUtils();

  const createPost = api.post.create.useMutation({
    onSuccess: async () => {
      // Invalider les queries pour refetch
      await utils.post.invalidate();
      setName("");
    },
    onError: (error) => {
      console.error("Erreur:", error.message);
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        createPost.mutate({ name });
      }}
      className="flex gap-2"
    >
      <Input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Nom du post"
      />
      <Button type="submit" disabled={createPost.isPending}>
        {createPost.isPending ? "Création..." : "Créer"}
      </Button>
    </form>
  );
}
```

### Suspense avec tRPC
```typescript
// src/components/features/posts/LatestPost.tsx
"use client";

import { api } from "~/trpc/react";

export function LatestPost() {
  // useSuspenseQuery pour Suspense
  const [latestPost] = api.post.getLatest.useSuspenseQuery();

  return (
    <div>
      {latestPost ? (
        <p>Dernier post: {latestPost.name}</p>
      ) : (
        <p>Aucun post</p>
      )}
    </div>
  );
}

// Utilisation avec Suspense
// src/app/posts/page.tsx
import { Suspense } from "react";
import { LatestPost } from "~/components/features/posts/LatestPost";

export default function PostsPage() {
  return (
    <Suspense fallback={<div>Chargement...</div>}>
      <LatestPost />
    </Suspense>
  );
}
```

### Optimistic Updates
```typescript
// src/components/features/posts/PostCard.tsx
"use client";

import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";

export function PostCard({ post }: { post: Post }) {
  const utils = api.useUtils();

  const deletePost = api.post.delete.useMutation({
    onMutate: async (deletedId) => {
      // Annuler les queries en cours
      await utils.post.getAll.cancel();

      // Snapshot de la valeur précédente
      const previousPosts = utils.post.getAll.getData();

      // Optimistic update
      utils.post.getAll.setData(undefined, (old) => {
        if (!old) return old;
        return {
          ...old,
          posts: old.posts.filter((p) => p.id !== deletedId),
        };
      });

      return { previousPosts };
    },
    onError: (err, deletedId, context) => {
      // Rollback en cas d'erreur
      if (context?.previousPosts) {
        utils.post.getAll.setData(undefined, context.previousPosts);
      }
    },
    onSettled: () => {
      // Refetch pour être sûr
      void utils.post.getAll.invalidate();
    },
  });

  return (
    <div>
      <h3>{post.name}</h3>
      <Button
        onClick={() => deletePost.mutate({ id: post.id })}
        disabled={deletePost.isPending}
      >
        Supprimer
      </Button>
    </div>
  );
}
```

---

## 3. State Management

### Zustand - State UI Global
```typescript
// src/stores/uiStore.ts
import { create } from "zustand";
import { devtools } from "zustand/middleware";

interface UIState {
  isSidebarOpen: boolean;
  isModalOpen: boolean;
  modalContent: React.ReactNode | null;
  theme: "light" | "dark" | "system";

  toggleSidebar: () => void;
  openModal: (content: React.ReactNode) => void;
  closeModal: () => void;
  setTheme: (theme: UIState["theme"]) => void;
}

export const useUIStore = create<UIState>()(
  devtools(
    (set) => ({
      isSidebarOpen: true,
      isModalOpen: false,
      modalContent: null,
      theme: "system",

      toggleSidebar: () =>
        set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),

      openModal: (content) =>
        set({ isModalOpen: true, modalContent: content }),

      closeModal: () =>
        set({ isModalOpen: false, modalContent: null }),

      setTheme: (theme) => set({ theme }),
    }),
    { name: "UIStore" }
  )
);
```

### Nuqs - Query Params State
```typescript
// src/hooks/usePostsFilters.ts
import { useQueryState, parseAsString, parseAsInteger } from "nuqs";

export function usePostsFilters() {
  const [search, setSearch] = useQueryState(
    "search",
    parseAsString.withDefault("")
  );
  const [page, setPage] = useQueryState(
    "page",
    parseAsInteger.withDefault(1)
  );
  const [category, setCategory] = useQueryState("category", parseAsString);

  return {
    search,
    setSearch,
    page,
    setPage,
    category,
    setCategory,
    resetFilters: () => {
      setSearch(null);
      setPage(null);
      setCategory(null);
    },
  };
}

// Utilisation avec tRPC
// src/components/features/posts/PostsList.tsx
"use client";

import { api } from "~/trpc/react";
import { usePostsFilters } from "~/hooks/usePostsFilters";

export function PostsList() {
  const { search, page, category } = usePostsFilters();

  const { data } = api.post.getAll.useQuery({
    page,
    pageSize: 20,
    search: search || undefined,
  });

  return (
    <div>
      {/* Liste des posts */}
    </div>
  );
}
```

### Provider Setup
```typescript
// src/app/layout.tsx
import { TRPCReactProvider } from "~/trpc/react";
import { NuqsAdapter } from "nuqs/adapters/next/app";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="fr">
      <body>
        <TRPCReactProvider>
          <NuqsAdapter>
            {children}
          </NuqsAdapter>
        </TRPCReactProvider>
      </body>
    </html>
  );
}
```

---

## 4. Composants shadcn/ui

### Structure de Composant UI
```typescript
// src/components/ui/button.tsx
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils";

const buttonVariants = cva(
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline:
          "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
  isLoading?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      className,
      variant,
      size,
      asChild = false,
      isLoading,
      disabled,
      children,
      ...props
    },
    ref
  ) => {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        disabled={disabled || isLoading}
        {...props}
      >
        {isLoading ? (
          <>
            <span className="animate-spin mr-2">⏳</span>
            Chargement...
          </>
        ) : (
          children
        )}
      </Comp>
    );
  }
);
Button.displayName = "Button";

export { Button, buttonVariants };
```

### Utilitaire cn
```typescript
// src/lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
```

---

## 5. Formulaires (React Hook Form + Zod)

### Schéma de Validation
```typescript
// src/lib/validations/post.ts
import { z } from "zod";

export const createPostSchema = z.object({
  name: z
    .string()
    .min(3, "Le nom doit contenir au moins 3 caractères")
    .max(100, "Le nom ne peut pas dépasser 100 caractères"),
  description: z
    .string()
    .max(500, "La description ne peut pas dépasser 500 caractères")
    .optional(),
});

export type CreatePostInput = z.infer<typeof createPostSchema>;
```

### Composant Formulaire avec tRPC
```typescript
// src/components/features/posts/PostForm.tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "~/components/ui/form";
import { createPostSchema, type CreatePostInput } from "~/lib/validations/post";

interface PostFormProps {
  onSuccess?: () => void;
  defaultValues?: Partial<CreatePostInput>;
}

export function PostForm({ onSuccess, defaultValues }: PostFormProps) {
  const utils = api.useUtils();

  const form = useForm<CreatePostInput>({
    resolver: zodResolver(createPostSchema),
    defaultValues: {
      name: "",
      description: "",
      ...defaultValues,
    },
  });

  const createPost = api.post.create.useMutation({
    onSuccess: async () => {
      await utils.post.invalidate();
      form.reset();
      onSuccess?.();
    },
  });

  const onSubmit = async (data: CreatePostInput) => {
    try {
      await createPost.mutateAsync(data);
    } catch (error) {
      // Géré par tRPC
    }
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Nom</FormLabel>
              <FormControl>
                <Input placeholder="Nom du post" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="description"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Description</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="Description du post"
                  {...field}
                />
              </FormControl>
              <FormDescription>
                Optionnel - Max 500 caractères
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit" isLoading={createPost.isPending}>
          Créer le post
        </Button>
      </form>
    </Form>
  );
}
```

---

## 6. Structure de Projet

### Organisation
```
src/
├── app/
│   ├── (auth)/
│   │   ├── login/
│   │   │   └── page.tsx
│   │   └── layout.tsx
│   ├── (dashboard)/
│   │   ├── dashboard/
│   │   │   └── page.tsx
│   │   └── layout.tsx
│   ├── api/
│   │   └── trpc/
│   │       └── [trpc]/
│   │           └── route.ts
│   ├── layout.tsx
│   └── page.tsx
├── components/
│   ├── ui/                        # shadcn/ui components
│   │   ├── button.tsx
│   │   ├── input.tsx
│   │   └── ...
│   ├── features/                  # Composants par feature
│   │   ├── posts/
│   │   │   ├── PostCard.tsx
│   │   │   ├── PostForm.tsx
│   │   │   └── PostsList.tsx
│   └── layout/                    # Composants de layout
│       ├── Header.tsx
│       └── Sidebar.tsx
├── hooks/                         # Custom hooks
│   ├── usePostsFilters.ts
│   └── useAuth.ts
├── stores/                        # Zustand stores
│   ├── uiStore.ts
│   └── postStore.ts
├── lib/
│   ├── utils.ts                   # Utilitaires
│   └── validations/               # Schémas Zod
│       └── post.ts
├── server/                         # Backend tRPC
│   ├── api/
│   │   ├── routers/
│   │   │   └── post.ts
│   │   ├── root.ts
│   │   └── trpc.ts
│   └── db.ts
└── trpc/                          # tRPC client
    ├── react.tsx
    └── server.ts
```

### Path Aliases
```json
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "~/*": ["./src/*"]
    }
  }
}
```

---

## 7. Internationalisation (next-intl)

### Configuration
```typescript
// src/i18n.ts
import { getRequestConfig } from 'next-intl/server';
import { cookies, headers } from 'next/headers';

export const locales = ['fr', 'en'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'fr';

export default getRequestConfig(async () => {
  const cookieStore = await cookies();
  const headersList = await headers();
  
  let locale: Locale = defaultLocale;
  
  const localeCookie = cookieStore.get('locale')?.value;
  if (localeCookie && locales.includes(localeCookie as Locale)) {
    locale = localeCookie as Locale;
  } else {
    const acceptLanguage = headersList.get('Accept-Language');
    if (acceptLanguage) {
      const preferred = acceptLanguage.split(',')[0].split('-')[0];
      if (locales.includes(preferred as Locale)) {
        locale = preferred as Locale;
      }
    }
  }
  
  return {
    locale,
    messages: (await import(`./messages/${locale}.json`)).default
  };
});
```

### Utilisation Client
```typescript
// src/components/features/Header.tsx
"use client";

import { useTranslations } from 'next-intl';
import { Button } from "~/components/ui/button";

export function Header() {
  const t = useTranslations('common');
  
  return (
    <header>
      <h1>{t('title')}</h1>
      <Button>{t('actions.save')}</Button>
    </header>
  );
}
```

### Utilisation Server
```typescript
// src/app/dashboard/page.tsx
import { getTranslations } from 'next-intl/server';

export default async function DashboardPage() {
  const t = await getTranslations('dashboard');
  
  return (
    <div>
      <h1>{t('title')}</h1>
    </div>
  );
}
```

### Messages
```json
// src/messages/fr.json
{
  "common": {
    "title": "Mon Application",
    "actions": {
      "save": "Enregistrer",
      "cancel": "Annuler"
    }
  },
  "dashboard": {
    "title": "Tableau de bord"
  }
}
```

---

## 8. Formatage de Dates (date-fns)

### Utilisation
```typescript
// src/components/features/posts/PostCard.tsx
import { format, formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale';

export function PostCard({ post }: { post: Post }) {
  return (
    <div>
      <p>{format(new Date(post.createdAt), 'PPP', { locale: fr })}</p>
      <p>{formatDistanceToNow(new Date(post.createdAt), { locale: fr, addSuffix: true })}</p>
    </div>
  );
}
```

### Helpers
```typescript
// src/lib/utils/date.ts
import { format, formatDistanceToNow, isToday, isYesterday } from 'date-fns';
import { fr } from 'date-fns/locale';

export function formatPostDate(date: Date | string) {
  const dateObj = typeof date === 'string' ? new Date(date) : date;
  
  if (isToday(dateObj)) {
    return formatDistanceToNow(dateObj, { locale: fr, addSuffix: true });
  }
  
  if (isYesterday(dateObj)) {
    return 'Hier';
  }
  
  return format(dateObj, 'PPP', { locale: fr });
}
```

---

## 9. Server Components avec tRPC

### Préchargement et Hydration
```typescript
// src/app/posts/page.tsx
import { Suspense } from "react";
import { api, HydrateClient } from "~/trpc/server";
import { PostsList } from "~/components/features/posts/PostsList";
import { PostsListSkeleton } from "~/components/skeletons/PostsListSkeleton";

export default async function PostsPage() {
  // Précharger les données côté serveur
  const initialData = await api.post.getAll({
    page: 1,
    pageSize: 20,
  });

  // Précharger pour le client
  void api.post.getLatest.prefetch();

  return (
    <HydrateClient>
      <div className="space-y-6">
        <h1>Posts</h1>
        <Suspense fallback={<PostsListSkeleton />}>
          <PostsList initialData={initialData} />
        </Suspense>
      </div>
    </HydrateClient>
  );
}
```

---

## 10. Error Handling

### Error Boundary
```typescript
// src/app/(dashboard)/error.tsx
"use client";

import { useEffect } from "react";
import { Button } from "~/components/ui/button";

interface ErrorProps {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function DashboardError({ error, reset }: ErrorProps) {
  useEffect(() => {
    console.error("Error:", error);
  }, [error]);

  return (
    <div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
      <h2 className="text-2xl font-bold">Une erreur est survenue</h2>
      <p className="text-muted-foreground">
        {error.message || "Une erreur inattendue s'est produite"}
      </p>
      {error.digest && (
        <p className="text-sm text-muted-foreground">
          Référence: {error.digest}
        </p>
      )}
      <Button onClick={reset}>Réessayer</Button>
    </div>
  );
}
```

### Gestion d'erreurs tRPC
```typescript
// src/components/features/posts/PostsList.tsx
"use client";

import { api } from "~/trpc/react";
import { TRPCClientError } from "@trpc/client";

export function PostsList() {
  const { data, error, isLoading } = api.post.getAll.useQuery({
    page: 1,
    pageSize: 20,
  });

  if (isLoading) return <div>Chargement...</div>;

  if (error) {
    if (error instanceof TRPCClientError) {
      if (error.data?.code === "UNAUTHORIZED") {
        return <div>Vous devez être connecté</div>;
      }
      if (error.data?.code === "NOT_FOUND") {
        return <div>Post non trouvé</div>;
      }
    }
    return <div>Erreur: {error.message}</div>;
  }

  return (
    <div>
      {data?.posts.map((post) => (
        <div key={post.id}>{post.name}</div>
      ))}
    </div>
  );
}
```

---

## 11. Performance

### Streaming avec Suspense
```typescript
// src/app/dashboard/page.tsx
import { Suspense } from "react";
import { DashboardStats } from "~/components/features/dashboard/DashboardStats";
import { RecentPosts } from "~/components/features/dashboard/RecentPosts";
import { StatsSkeleton, PostsSkeleton } from "~/components/skeletons";

export default function DashboardPage() {
  return (
    <div className="grid gap-6">
      <Suspense fallback={<StatsSkeleton />}>
        <DashboardStats />
      </Suspense>

      <Suspense fallback={<PostsSkeleton />}>
        <RecentPosts />
      </Suspense>
    </div>
  );
}
```

### Optimisation Images
```typescript
// src/components/ui/optimized-image.tsx
import Image from "next/image";

interface OptimizedImageProps {
  src: string;
  alt: string;
  width?: number;
  height?: number;
  priority?: boolean;
  className?: string;
}

export function OptimizedImage({
  src,
  alt,
  width,
  height,
  priority = false,
  className,
}: OptimizedImageProps) {
  return (
    <Image
      src={src}
      alt={alt}
      width={width}
      height={height}
      priority={priority}
      loading={priority ? "eager" : "lazy"}
      className={className}
    />
  );
}
```

---

## 12. Checklist Frontend

### Structure
- [ ] App Router avec groupes de routes `(folder)`
- [ ] Server Components par défaut
- [ ] Client Components avec `"use client"` si nécessaire
- [ ] Path aliases `~/` configurés
- [ ] Structure layer-based respectée

### tRPC
- [ ] Hooks tRPC utilisés (`useQuery`, `useMutation`)
- [ ] Préchargement côté serveur avec `api.router.procedure.prefetch()`
- [ ] Hydration avec `HydrateClient`
- [ ] Optimistic updates pour mutations
- [ ] Error handling avec TRPCError

### State Management
- [ ] Zustand pour state UI global
- [ ] Nuqs pour query params
- [ ] tRPC React Query pour server state
- [ ] Providers configurés dans `layout.tsx`

### Composants
- [ ] shadcn/ui utilisé pour UI de base
- [ ] Composants features isolés
- [ ] Props typées avec TypeScript
- [ ] Variants avec `class-variance-authority`

### Formulaires
- [ ] React Hook Form configuré
- [ ] Validation Zod
- [ ] Messages d'erreur accessibles
- [ ] Loading states gérés
- [ ] Intégration avec tRPC mutations

### Internationalisation
- [ ] next-intl configuré
- [ ] Messages fr/en créés
- [ ] Détection langue (cookies/headers)
- [ ] Utilisation dans composants (useTranslations/getTranslations)

### Dates
- [ ] date-fns installé
- [ ] Helpers de formatage créés
- [ ] Locale configurée (fr)

### Performance
- [ ] Streaming avec Suspense
- [ ] Préchargement tRPC côté serveur
- [ ] Images optimisées (next/image)
- [ ] Fonts optimisées (next/font)

### Accessibilité
- [ ] Labels et aria-* appropriés
- [ ] Navigation clavier fonctionnelle
- [ ] Contraste suffisant
- [ ] Screen reader compatible

---

**Règle d'or** : Server Components pour la logique et les données (via tRPC), Client Components pour l'interactivité. Utiliser tRPC React pour toutes les opérations API côté client. Performance et accessibilité sont des priorités.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.