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é.
avec React Router 7
Posez vos questions 24/7 à notre IA experte en React Router 7
Validez vos acquis avec des quiz personnalisés et un feedback instantané
La branche main utilisait encore la configuration manuelle des routes.
On passe au file-based routing pour :
routes.ts;.tsx dans app/routes.1npm add -D @react-router/remix-routes-option-adapter remix-flat-routes
root.tsx1import {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 ?
flatRoutes parcourt app/routes/** : plus besoin de déclarer les routes à la main.decimal par des integer en centimesdecimal apporte une précision inutile pour un petit catalogue et complique les calculs.
On stocke désormais le prix en centimes.
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.
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.
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
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 ?
Awaited<ReturnType<typeof getProducts>>[number].app/routes/products.tsx1import { 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}
<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.grid-cols-2, md:grid-cols-3…).e.preventDefault().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>
1npm run dev
localhost:5173/products affiche 10 produits, cartes alignées sans bord-radius.priceCents / 100).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
useDebounce côté client + loader filtré.links() (module SEO).useFetcher() pour une UI optimiste (prochaine leçon).Ton catalogue est désormais prêt, typé et responsif – bravo !
Quelle est la principale différence entre les composants client et serveur dans React ?
Quelle technique est recommandée pour éviter les rendus inutiles dans React ?
Quel hook permet de gérer les effets de bord dans un composant React ?
Comment implémenter la gestion des erreurs pour les requêtes API dans React ?
Quelle est la meilleure pratique pour déployer une application React en production ?