Hébergement d’images S3 pour ton shop React Router 7
Apprends à créer un bucket S3 public, uploader, lister et supprimer tes images depuis React Router 7. Optimise ton dépôt et ton VPS dès maintenant.
Pourquoi stocker nos images sur S3 ?
Notre boutique doit afficher des visuels HD sans faire exploser le dépôt Git ni saturer le VPS.
Un bucket S3 public répond à 3 besoins :
- stockage illimité et facturation « à l’usage » (quelques centimes / mois) ;
- CDN intégré – Amazon réplique les fichiers dans le monde entier ;
- API REST + SDK officiels pour streamer, lister et supprimer les objets.
Dans cette leçon on :
- crée un bucket S3 public et un utilisateur IAM limité ;
- génère les clés d’accès et les copie dans
.env; - écrit un utilitaire
s3.server.ts(upload, list, delete) ; - ajoute une interface « Librairie d’images » dans l’admin ;
- lie les images S3 aux produits via Prisma.
1 Créer le bucket public
- Connecte-toi à la console AWS → S3 → « Create bucket ».
- Nom :
shop-router(unique sur AWS). - Désactive « Block all public access » (nos images sont publiques).
- Valide l’avertissement.
Pourquoi désactiver le blocage ?
Nous ne stockons que des visuels catalogue.
Pour des documents privés (factures, avatars non publics…) on garderait le blocage
et on générerait des URL signées.
2 Créer l’utilisateur IAM
- AWS → IAM → Users → « Add user »
Nom :shop-router-image-manager. - Type d’accès : « Programmatic access ».
- Permissions : « Attach policies directly » →
AmazonS3FullAccess.
(on affinera plus tard). - Génère l’
Access key IDet leSecret access key. Copie-les.
1AWS_ACCESS_KEY_ID=AKIAXXXXXXX2AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxx3AWS_REGION=eu-west-3 # Paris4AWS_S3_BUCKET_NAME=shop-router
Warning
.env dans .gitignore.3 Autoriser le bucket : Bucket Policy
La clé IAM n’est pas suffisante : on doit autoriser le bucket à servir/lister :
1{2"Version": "2012-10-17",3"Statement": [4{5"Sid": "PublicRead",6"Effect": "Allow",7"Principal": "*",8"Action": "s3:GetObject",9"Resource": "arn:aws:s3:::shop-router/*"10},11{12"Sid": "AppWriteDelete",13"Effect": "Allow",14"Principal": { "AWS": "arn:aws:iam::123456789012:user/shop-router-image-manager" },15"Action": [16"s3:PutObject",17"s3:DeleteObject",18"s3:ListBucket"19],20"Resource": [21"arn:aws:s3:::shop-router",22"arn:aws:s3:::shop-router/*"23]24}25]26}
Explications :
- bloc 1 → tout le monde peut lire (
GetObject) n’importe quelle image ; - bloc 2 → seul l’utilisateur IAM peut écrire / supprimer.
Copie ce JSON dans « Permissions » → Bucket Policy.
4 Installer le SDK S3 côté serveur
1npm i @aws-sdk/client-s3
Utilitaire partagé
1import {2S3Client,3PutObjectCommand,4DeleteObjectCommand,5ListObjectsV2Command,6} from "@aws-sdk/client-s3";78const s3 = new S3Client({9region: process.env.AWS_REGION,10credentials: {11accessKeyId: process.env.AWS_ACCESS_KEY_ID!,12secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,13},14});1516const BUCKET = process.env.AWS_S3_BUCKET_NAME!;1718// Upload ------------------------------------------------19export async function uploadFile({ file, key }: { file: File; key: string }) {20await s3.send(21new PutObjectCommand({22Bucket: BUCKET,23Key: key,24Body: Buffer.from(await file.arrayBuffer()),25ContentType: file.type,26CacheControl: "public,max-age=31536000,immutable",27}),28);29return `https://${BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;30}3132// Delete -----------------------------------------------33export const deleteFile = (key: string) =>34s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));3536// List -------------------------------------------------37export async function listObjects(prefix = "library/") {38const { Contents } = await s3.send(39new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix }),40);41return (42Contents?.filter((o) => o.Key)43.map((o) => ({44key: o.Key!,45url: `https://${BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${o.Key}`,46size: o.Size ?? 0,47lastModified: o.LastModified ?? new Date(),48})) ?? []49);50}5152// Helper clé unique ------------------------------------53export const makeKey = (fileName: string, folder = "library") =>54`${folder}/${Date.now()}_${fileName.replace(/[^a-z0-9.\-]/gi, "_")}`;
CacheControl= immutable 1 an → images jamais re-téléchargées.makeKey()évite les collisions.
5 Interface « Librairie » dans l’admin
Loader : récupérer la liste S3
1export async function loader() {2return data({ images: await listObjects() });3}
Action : upload & delete (multipart)
1import { parseFormData } from "@mjackson/form-data-parser";23export const LibrarySchema = z.discriminatedUnion("intent", [4z.object({5intent: z.literal("upload"),6files: z7.array(z.instanceof(File))8.min(1)9.max(10)10.refine((arr) => arr.every((f) => f.type.startsWith("image/")), "images only")11.refine((arr) => arr.every((f) => f.size < 10 * 1024 * 1024), "< 10 Mo"),12}),13z.object({14intent: z.literal("delete"),15key: z.string().min(1),16}),17]);1819export async function action({ request }: ActionFunctionArgs) {20const formData = await parseFormData(request);21const submission = LibrarySchema.safeParse(Object.fromEntries(formData));2223if (!submission.success)24return badRequest(submission.error.flatten());2526if (submission.data.intent === "upload") {27await Promise.all(28submission.data.files.map((file) =>29uploadFile({ file, key: makeKey(file.name) }),30),31);32} else {33await deleteFile(submission.data.key);34}3536return redirect("/admin/library");37}
@mjackson/form-data-parsersimplifie le multipart.- Discriminated union : un seul endpoint gère 2 intentions.
Composant React
1export default function Library() {2const { images } = useLoaderData<typeof loader>();3const navigation = useNavigation();4const uploading = navigation.state === "submitting";56return (7<section className="space-y-8">8{/* formulaire d’upload */}9<Form method="post" encType="multipart/form-data" className="flex gap-4">10<input type="hidden" name="intent" value="upload" />11<input12type="file"13name="files"14accept="image/*"15multiple16className="file:mr-4"17/>18<Button type="submit" disabled={uploading} isLoading={uploading}>19{uploading ? "Upload..." : "Uploader"}20</Button>21</Form>2223{/* grille d’images */}24<ul className="grid gap-6 grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">25{images.map((img) => (26<li key={img.key} className="group relative">27<img28src={img.url}29alt={img.key}30className="aspect-[4/3] w-full object-cover rounded-lg shadow"31/>32<fetcher.Form method="post" className="absolute top-2 right-2">33<input type="hidden" name="intent" value="delete" />34<input type="hidden" name="key" value={img.key} />35<Button36size="icon"37variant="destructive"38className="opacity-0 group-hover:opacity-100"39>40<Trash2 className="size-4" />41</Button>42</fetcher.Form>43</li>44))}45</ul>46</section>47);48}
useFetcher()sur chaque carte pour supprimer sans reloader toute la page.- CSS :
opacity-0 group-hover:opacity-100→ les boutons n’apparaissent qu’au survol.
6 Lier des images à un produit
Migration : many-to-many
1model Product {2id String @id @default(uuid())3name String4/* … */5images Image[] @relation("ProductImages")6}78model Image {9id String @id @default(uuid())10url String @unique11alt String?12products Product[] @relation("ProductImages")13}
1npx prisma migrate dev --name m2m-product-images
Helper serveur
1export async function linkImagesToProduct({2productId,3imageUrls,4}: {5productId: string;6imageUrls: string[];7}) {8const connects = await Promise.all(9imageUrls.map(async (url) => {10const image =11(await prisma.image.findUnique({ where: { url } })) ??12(await prisma.image.create({ data: { url } }));13return { id: image.id };14}),15);16await prisma.product.update({17where: { id: productId },18data: { images: { connect: connects } },19});20return { linkedCount: connects.length };21}
Sélecteur dans la fiche produit
1// Bouton qui ouvre un <Dialog> avec la grid S3 et renvoie onConfirm(imageUrls[])
Dans l’action de products.$productSlug.tsx :
1case "link-images":2await linkImagesToProduct(submission.value);3return data({ success: "Images liées !" });
7 Tests manuels
- Upload 3 images dans « / admin / library ».
- Ouvre un produit → « Choisir des images », sélectionne 2 visuels → confirmer.
- Les miniatures apparaissent instantanément grâce au refetch automatique
(
useFetcher()+ redirection interne). - Supprime un visuel → la card disparaît sans reload (optimistic UI).
Récapitulatif
- AWS S3 sert les visuels publics via un bucket configuré en lecture anonyme.
- IAM user : droit en lecture publique + droit d’écriture/suppression pour l’application.
- sdk v3 (
@aws-sdk/client-s3) + utilitaire maison = upload / list / delete. - form-data-parser lit les fichiers dans l’
actionsans boilerplate. - Interface admin complète : upload multiple, preview, copie d’URL, suppression.
- Many-to-many Prisma pour lier un produit à plusieurs images (et inversement).
Ta boutique peut désormais héberger des centaines d’images sans alourdir le repo Git ni ton VPS – prochaine étape : brancher Stripe pour encaisser les commandes !
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 ?