Liste des produits (catalogue e-commerce) avec React Router 7
Découvre comment créer une page catalogue produits en routing file-based avec React Router 7 et Prisma. Exemples clairs, UI moderne et code optimisé.
Basculer sur le file-based routing
La branche main utilisait encore la configuration manuelle des routes.
On passe au file-based routing pour :
- ne plus toucher au fichier de configuration
routes.ts; - détecter automatiquement chaque fichier
.tsxdansapp/routes.
Installation des plugins
1npm add -D @react-router/remix-routes-option-adapter remix-flat-routes
Nouveau fichier root.tsx
1import {2Links,3Meta,4Outlet,5Scripts,6ScrollRestoration,7} from "react-router";8import { remixRoutesOptionAdapter } from "@react-router/remix-routes-option-adapter";9import { flatRoutes } from "remix-flat-routes";1011/* … Links + Layout inchangés … */1213export const routeOptions = remixRoutesOptionAdapter((defineRoutes) =>14flatRoutes("routes", defineRoutes),15);
Pourquoi ?
flatRoutesparcourtapp/routes/**: plus besoin de déclarer les routes à la main.- L’auto-refresh de Vite recharge la liste quand tu ajoutes un fichier.
Remplacer les decimal par des integer en centimes
decimal apporte une précision inutile pour un petit catalogue et complique les calculs.
On stocke désormais le prix en centimes.
Diff du schéma
1-model Product {2- price Decimal @db.Decimal(10, 2)3+model Product {4+ priceCents Int @default(0)
Même traitement pour Order, OrderItem, Invoice.
Migration
1npx prisma migrate dev --name replace_decimal_par_integer
Prisma réclame une valeur par défaut car des lignes existent déjà :
on fixe @default(0) puis on accepte la suppression de l’ancienne colonne price.
Adapter le seed
1await prisma.product.create({2data: {3name,4slug: `${slug(name)}-${i}`,5description: `Description de ${name}`,6priceCents: Math.round(Math.random() * 15000) + 500, // 5 € – 150 €7stock: Math.floor(Math.random() * 100),8},9});
Exécution :
1npx prisma db seed
Extraire la requête Prisma dans un fichier serveur
1import { prisma } from "./db.server";23export async function getProducts() {4const products = await prisma.product.findMany({5select: {6id: true,7name: true,8description: true,9priceCents: true,10},11});1213// image placeholder stable (Picsum)14return products.map((p) => ({15...p,16imageUrl: `https://picsum.photos/seed/${p.id}/600/400`,17}));18}
Pourquoi ?
- Centralise la logique DB.
- Le type est inféré :
Awaited<ReturnType<typeof getProducts>>[number].
Page liste des produits
Route app/routes/products.tsx
1import { useLoaderData } from "react-router";2import { getProducts } from "~/server/products.server";3import { ProductList } from "~/components/products/ProductList";45export async function loader() {6return getProducts();7}89export default function ProductsRoute() {10const products = useLoaderData<typeof loader>();1112return (13<main className="flex justify-center pt-12 pb-8">14<div className="w-full max-w-7xl px-4">15<header className="mb-6">16<h1 className="text-3xl font-semibold text-primary">Catalogue</h1>17</header>1819<ProductList products={products} />20</div>21</main>22);23}
Composants UI shadcn
<ProductCard>
1import { ShoppingCart } from "lucide-react";2import { Link } from "react-router";3import { Card } from "~/components/ui/card";4import { Button } from "~/components/ui/button";5import { cn } from "~/lib/utils";6import type { getProducts } from "~/server/products.server";78export function ProductCard({9product,10className,11}: {12product: Awaited<ReturnType<typeof getProducts>>[number];13className?: string;14}) {15return (16<Card17className={cn(18"relative flex h-full flex-col overflow-hidden rounded-none p-0 shadow",19className,20)}21>22{/* lien couvrant toute la carte */}23<Link24to={`/products/${product.slug}`}25className="absolute inset-0 z-10"26aria-label={`Voir ${product.name}`}27/>2829{/* image 16/9 */}30<img31src={product.imageUrl}32alt={product.name}33className="aspect-video w-full object-cover"34/>3536<div className="flex flex-grow flex-col p-2 lg:p-4">37<h3 className="truncate text-sm font-semibold lg:text-base">38{product.name}39</h3>40<p className="mt-1 line-clamp-2 text-xs text-muted-foreground lg:text-sm">41{product.description}42</p>4344<div className="mt-auto flex items-center justify-between pt-2">45<span className="text-sm font-medium lg:text-base">46{(product.priceCents / 100).toFixed(2)}{" €"}47</span>48<Button49size="sm"50className="rounded-none p-2 lg:p-2.5"51onClick={(e) => {52e.preventDefault(); // évite la navigation53/* TODO : addToCart(product.id) */54}}55>56<ShoppingCart className="h-4 w-4" />57</Button>58</div>59</div>60</Card>61);62}
<ProductList>
1import { ProductCard } from "./ProductCard";2import type { getProducts } from "~/server/products.server";34export function ProductList({5products,6}: {7products: Awaited<ReturnType<typeof getProducts>>;8}) {9return (10<section11className="grid auto-rows-fr gap-3 p-212grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-513lg:gap-4 lg:p-4"14>15{products.map((p) => (16<ProductCard key={p.id} product={p} />17))}18</section>19);20}
Explications :
auto-rows-fr: toutes les cartes ont la même hauteur.- Responsive via classes Tailwind (
grid-cols-2,md:grid-cols-3…). - Bouton « Ajouter au panier » ne casse pas le lien :
e.preventDefault().
Mise à jour de la page d’accueil temporaire
Pour tester rapidement, l’index reprend la même requête :
1-<h1 className="text-4xl …">Shop Router</h1>2+<h1 className="text-4xl …">Bienvenue sur Shop Router</h1>
Vérifier le tout
1npm run dev
localhost:5173/productsaffiche 10 produits, cartes alignées sans bord-radius.- Prix formatés (
priceCents / 100). - Page mobile : deux cartes visibles dès le premier viewport.
Versionner la progression
1git checkout -b 7-3-ajout-de-la-page-liste-des-produits2git add .3git commit -m "feat(products): list page + card UI + decimal→centimes"4git push -u origin 7-3-ajout-de-la-page-liste-des-produits
Pour aller plus loin
- Ajouter une barre de recherche :
useDebouncecôté client + loader filtré. - Précharger les images LCP via la fonction
links()(module SEO). - Brancher le panier avec
useFetcher()pour une UI optimiste (prochaine leçon).
Ton catalogue est désormais prêt, typé et responsif – bravo !
Comprendre les concepts fondamentaux
Quelle est la principale différence entre les composants client et serveur dans React ?
Optimisation des performances
Quelle technique est recommandée pour éviter les rendus inutiles dans React ?
Architecture des données
Quel hook permet de gérer les effets de bord dans un composant React ?