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.
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é
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 :
Dans cette leçon on :
.env
;s3.server.ts
(upload, list, delete) ;shop-router
(unique sur AWS).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.
shop-router-image-manager
.AmazonS3FullAccess
.Access key ID
et le Secret access key
. Copie-les.1AWS_ACCESS_KEY_ID=AKIAXXXXXXX2AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxx3AWS_REGION=eu-west-3 # Paris4AWS_S3_BUCKET_NAME=shop-router
.env
dans .gitignore
.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 :
GetObject
) n’importe quelle image ;Copie ce JSON dans « Permissions » → Bucket Policy.
1npm i @aws-sdk/client-s3
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.1export async function loader() {2return data({ images: await listObjects() });3}
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-parser
simplifie le multipart.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.opacity-0 group-hover:opacity-100
→ les boutons n’apparaissent qu’au survol.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
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}
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 !" });
useFetcher()
+ redirection interne).@aws-sdk/client-s3
) + utilitaire maison = upload / list / delete.action
sans boilerplate.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 !
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 ?