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.

6 min read
Déverrouillez votre potentiel

avec React Router 7

Vous en avez marre de...

❌ perdre du temps à chercher des informations éparpillées
❌ ne pas avoir de retour sur votre progression
Assistant IA spécialisé

Posez vos questions 24/7 à notre IA experte en React Router 7

Quiz interactifs

Validez vos acquis avec des quiz personnalisés et un feedback instantané

9 modules
72 leçons
Accès à vie
299.49
-35%

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 :

  1. stockage illimité et facturation « à l’usage » (quelques centimes / mois) ;
  2. CDN intégré – Amazon réplique les fichiers dans le monde entier ;
  3. API REST + SDK officiels pour streamer, lister et supprimer les objets.

Dans cette leçon on :

  1. crée un bucket S3 public et un utilisateur IAM limité ;
  2. génère les clés d’accès et les copie dans .env ;
  3. écrit un utilitaire s3.server.ts (upload, list, delete) ;
  4. ajoute une interface « Librairie d’images » dans l’admin ;
  5. lie les images S3 aux produits via Prisma.

1 Créer le bucket public

  1. Connecte-toi à la console AWS → S3 → « Create bucket ».
  2. Nom : shop-router (unique sur AWS).
  3. Désactive « Block all public access » (nos images sont publiques).
  4. 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

  1. AWS → IAM → Users → « Add user »
    Nom : shop-router-image-manager.
  2. Type d’accès : « Programmatic access ».
  3. Permissions : « Attach policies directly » → AmazonS3FullAccess.
    (on affinera plus tard).
  4. Génère l’Access key ID et le Secret access key. Copie-les.
.env
1
AWS_ACCESS_KEY_ID=AKIAXXXXXXX
2
AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxx
3
AWS_REGION=eu-west-3 # Paris
4
AWS_S3_BUCKET_NAME=shop-router

3 Autoriser le bucket : Bucket Policy

La clé IAM n’est pas suffisante : on doit autoriser le bucket à servir/lister :

s3/bucket-policy.json
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

Terminal
1
npm i @aws-sdk/client-s3

Utilitaire partagé

app/server/s3.server.ts
1
import {
2
S3Client,
3
PutObjectCommand,
4
DeleteObjectCommand,
5
ListObjectsV2Command,
6
} from "@aws-sdk/client-s3";
7
8
const s3 = new S3Client({
9
region: process.env.AWS_REGION,
10
credentials: {
11
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
12
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
13
},
14
});
15
16
const BUCKET = process.env.AWS_S3_BUCKET_NAME!;
17
18
// Upload ------------------------------------------------
19
export async function uploadFile({ file, key }: { file: File; key: string }) {
20
await s3.send(
21
new PutObjectCommand({
22
Bucket: BUCKET,
23
Key: key,
24
Body: Buffer.from(await file.arrayBuffer()),
25
ContentType: file.type,
26
CacheControl: "public,max-age=31536000,immutable",
27
}),
28
);
29
return `https://${BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
30
}
31
32
// Delete -----------------------------------------------
33
export const deleteFile = (key: string) =>
34
s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
35
36
// List -------------------------------------------------
37
export async function listObjects(prefix = "library/") {
38
const { Contents } = await s3.send(
39
new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix }),
40
);
41
return (
42
Contents?.filter((o) => o.Key)
43
.map((o) => ({
44
key: o.Key!,
45
url: `https://${BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${o.Key}`,
46
size: o.Size ?? 0,
47
lastModified: o.LastModified ?? new Date(),
48
})) ?? []
49
);
50
}
51
52
// Helper clé unique ------------------------------------
53
export 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

app/routes/admin+/library.tsx
1
export async function loader() {
2
return data({ images: await listObjects() });
3
}

Action : upload & delete (multipart)

app/routes/admin+/library.tsx {1-40}
1
import { parseFormData } from "@mjackson/form-data-parser";
2
3
export const LibrarySchema = z.discriminatedUnion("intent", [
4
z.object({
5
intent: z.literal("upload"),
6
files: z
7
.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
}),
13
z.object({
14
intent: z.literal("delete"),
15
key: z.string().min(1),
16
}),
17
]);
18
19
export async function action({ request }: ActionFunctionArgs) {
20
const formData = await parseFormData(request);
21
const submission = LibrarySchema.safeParse(Object.fromEntries(formData));
22
23
if (!submission.success)
24
return badRequest(submission.error.flatten());
25
26
if (submission.data.intent === "upload") {
27
await Promise.all(
28
submission.data.files.map((file) =>
29
uploadFile({ file, key: makeKey(file.name) }),
30
),
31
);
32
} else {
33
await deleteFile(submission.data.key);
34
}
35
36
return redirect("/admin/library");
37
}
  • @mjackson/form-data-parser simplifie le multipart.
  • Discriminated union : un seul endpoint gère 2 intentions.

Composant React

app/routes/admin+/library.tsx
1
export default function Library() {
2
const { images } = useLoaderData<typeof loader>();
3
const navigation = useNavigation();
4
const uploading = navigation.state === "submitting";
5
6
return (
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
<input
12
type="file"
13
name="files"
14
accept="image/*"
15
multiple
16
className="file:mr-4"
17
/>
18
<Button type="submit" disabled={uploading} isLoading={uploading}>
19
{uploading ? "Upload..." : "Uploader"}
20
</Button>
21
</Form>
22
23
{/* 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
<img
28
src={img.url}
29
alt={img.key}
30
className="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
<Button
36
size="icon"
37
variant="destructive"
38
className="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

prisma/schema.prisma
1
model Product {
2
id String @id @default(uuid())
3
name String
4
/* … */
5
images Image[] @relation("ProductImages")
6
}
7
8
model Image {
9
id String @id @default(uuid())
10
url String @unique
11
alt String?
12
products Product[] @relation("ProductImages")
13
}
Terminal
1
npx prisma migrate dev --name m2m-product-images

Helper serveur

app/server/db.server.ts
1
export async function linkImagesToProduct({
2
productId,
3
imageUrls,
4
}: {
5
productId: string;
6
imageUrls: string[];
7
}) {
8
const connects = await Promise.all(
9
imageUrls.map(async (url) => {
10
const image =
11
(await prisma.image.findUnique({ where: { url } })) ??
12
(await prisma.image.create({ data: { url } }));
13
return { id: image.id };
14
}),
15
);
16
await prisma.product.update({
17
where: { id: productId },
18
data: { images: { connect: connects } },
19
});
20
return { linkedCount: connects.length };
21
}

Sélecteur dans la fiche produit

app/components/image-selector.tsx
1
// Bouton qui ouvre un <Dialog> avec la grid S3 et renvoie onConfirm(imageUrls[])

Dans l’action de products.$productSlug.tsx :

1
case "link-images":
2
await linkImagesToProduct(submission.value);
3
return data({ success: "Images liées !" });

7 Tests manuels

  1. Upload 3 images dans « / admin / library ».
  2. Ouvre un produit → « Choisir des images », sélectionne 2 visuels → confirmer.
  3. Les miniatures apparaissent instantanément grâce au refetch automatique (useFetcher() + redirection interne).
  4. 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’action sans 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 !

Premium
Quiz interactif
Testez vos connaissances et validez votre compréhension du module avec notre quiz interactif.
1

Comprendre les concepts fondamentaux

Quelle est la principale différence entre les composants client et serveur dans React ?

Les composants client s'exécutent uniquement dans le navigateur
Les composants serveur peuvent utiliser useState
Les composants client sont plus rapides
Il n'y a aucune différence significative
2

Optimisation des performances

Quelle technique est recommandée pour éviter les rendus inutiles dans React ?

Utiliser React.memo pour les composants fonctionnels
Ajouter plus d'états locaux
Éviter d'utiliser les props
Toujours utiliser les class components
3

Architecture des données

Quel hook permet de gérer les effets de bord dans un composant React ?

useEffect
useState
useMemo
useContext
4

Gestion des erreurs

Comment implémenter la gestion des erreurs pour les requêtes API dans React ?

Utiliser try/catch avec async/await
Ignorer les erreurs
Toujours afficher un message d'erreur
Rediriger l'utilisateur
5

Déploiement et CI/CD

Quelle est la meilleure pratique pour déployer une application React en production ?

Utiliser un service CI/CD comme GitHub Actions
Copier les fichiers manuellement via FTP
Envoyer le code source complet
Ne jamais mettre à jour l'application

Débloquez ce quiz et tous les autres contenus premium en achetant ce cours