L'upload de fichiers fait partie de ces fonctionnalités qu'on croit triviales jusqu'au moment de les implémenter correctement. Avatar de profil, pièces jointes, documents PDF, images produits : presque tous les projets en ont besoin, et presque tous commencent par la mauvaise approche — faire transiter le fichier par leur propre serveur. Résultat : mémoire saturée, timeouts sur les gros fichiers, et une facture serveur qui gonfle.
Dans cet article, je vous montre la méthode que j'utilise en production : les presigned URLs S3. Le principe : votre backend NestJS génère une URL d'upload temporaire et sécurisée, et le navigateur envoie le fichier directement vers le stockage, sans jamais passer par votre API. Votre serveur ne voit jamais un seul octet du fichier — il ne fait qu'autoriser et valider.
Pourquoi éviter l'upload via votre propre serveur ?
L'approche naïve consiste à envoyer le fichier en multipart/form-data vers votre API, qui le reçoit en mémoire (ou sur disque) puis le pousse vers le stockage. Ça fonctionne pour un POC, mais ça pose trois problèmes concrets en production.
La mémoire et la bande passante. Chaque fichier transite deux fois : navigateur → serveur, puis serveur → stockage. Un utilisateur qui uploade une vidéo de 200 Mo mobilise votre process Node.js pendant toute la durée du transfert. Multipliez par quelques utilisateurs simultanés et votre API devient indisponible pour tout le reste.
Les limites d'infrastructure. La plupart des plateformes serverless (Vercel, AWS Lambda) plafonnent la taille du body des requêtes — 4,5 Mo sur Vercel pour les fonctions serverless. Impossible d'uploader quoi que ce soit de sérieux en passant par une API route.
Le coût. Vous payez la bande passante entrante et sortante de votre serveur pour des octets qui n'avaient aucune raison d'y passer.
Avec les presigned URLs, le flux devient : le client demande une autorisation à votre API (quelques millisecondes, quelques octets de JSON), puis uploade directement vers S3. Votre serveur reste léger, le transfert est rapide, et ça scale sans effort.
Le flux complet en trois étapes
Avant de plonger dans le code, voici le déroulé :
- Le client demande une presigned URL à votre API NestJS en précisant le nom, le type MIME et la taille du fichier.
- L'API valide la demande (utilisateur authentifié, type de fichier autorisé, taille acceptable), génère une clé unique et signe une URL d'upload valable quelques minutes.
- Le navigateur envoie le fichier directement vers S3 avec un simple
PUT, puis confirme à l'API que l'upload est terminé pour enregistrer la référence en base.
Ce pattern fonctionne à l'identique avec AWS S3, mais aussi avec tous les stockages compatibles S3 : Cloudflare R2 (mon choix habituel, zéro frais de sortie), MinIO en auto-hébergé, Scaleway Object Storage, ou Backblaze B2.
Côté NestJS : générer la presigned URL
Installons les dépendances AWS SDK v3 :
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Créons d'abord un service dédié au stockage. Il encapsule le client S3 et expose une méthode pour générer les URLs signées :
// src/storage/storage.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';
@Injectable()
export class StorageService {
private readonly s3: S3Client;
private readonly bucket: string;
constructor(private readonly config: ConfigService) {
this.s3 = new S3Client({
region: this.config.getOrThrow<string>('S3_REGION'),
endpoint: this.config.get<string>('S3_ENDPOINT'), // utile pour R2/MinIO
credentials: {
accessKeyId: this.config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
secretAccessKey: this.config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
},
});
this.bucket = this.config.getOrThrow<string>('S3_BUCKET');
}
async createPresignedUploadUrl(params: {
userId: string;
filename: string;
contentType: string;
contentLength: number;
}): Promise<{ uploadUrl: string; key: string }> {
// Clé unique : on ne fait JAMAIS confiance au nom de fichier client
const extension = params.filename.split('.').pop()?.toLowerCase() ?? 'bin';
const key = `uploads/${params.userId}/${randomUUID()}.${extension}`;
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
ContentType: params.contentType,
ContentLength: params.contentLength,
});
const uploadUrl = await getSignedUrl(this.s3, command, {
expiresIn: 300, // 5 minutes, pas plus
});
return { uploadUrl, key };
}
}
Deux détails comptent ici. La clé est générée avec un randomUUID() : le nom de fichier fourni par le client ne sert qu'à récupérer l'extension, jamais comme identifiant — c'est une source classique de collisions et d'injections de chemin. Et l'URL expire en 5 minutes : assez pour lancer l'upload, pas assez pour être réutilisée si elle fuite.
Valider la demande avec un DTO strict
On expose ensuite un endpoint qui valide la demande avant de signer quoi que ce soit. C'est ici que se joue la sécurité, pas côté client :
// src/storage/dto/request-upload.dto.ts
import { IsIn, IsInt, IsString, Max, MaxLength, Min } from 'class-validator';
export const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
] as const;
export class RequestUploadDto {
@IsString()
@MaxLength(255)
filename!: string;
@IsIn(ALLOWED_MIME_TYPES)
contentType!: (typeof ALLOWED_MIME_TYPES)[number];
@IsInt()
@Min(1)
@Max(10 * 1024 * 1024) // 10 Mo max
contentLength!: number;
}
// src/storage/storage.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { StorageService } from './storage.service';
import { RequestUploadDto } from './dto/request-upload.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@Controller('storage')
@UseGuards(JwtAuthGuard)
export class StorageController {
constructor(private readonly storage: StorageService) {}
@Post('request-upload')
async requestUpload(
@CurrentUser('id') userId: string,
@Body() dto: RequestUploadDto,
) {
return this.storage.createPresignedUploadUrl({
userId,
filename: dto.filename,
contentType: dto.contentType,
contentLength: dto.contentLength,
});
}
}
L'endpoint est protégé par un guard JWT : seul un utilisateur authentifié peut obtenir une URL d'upload. La liste blanche de types MIME et la limite de taille sont imposées côté serveur — un client malveillant peut bien envoyer ce qu'il veut dans sa requête, il n'obtiendra jamais d'URL signée pour un .exe de 2 Go. Pensez aussi à limiter la fréquence des demandes : j'ai détaillé la mise en place dans mon article sur le rate limiting NestJS avec Throttler.
Notez que ContentType et ContentLength sont inclus dans la signature de la commande PutObject : si le client tente d'uploader un fichier d'un autre type ou d'une autre taille que ce qu'il a déclaré, S3 rejette la requête. La validation n'est pas déclarative, elle est cryptographiquement verrouillée.
Côté Next.js : uploader avec suivi de progression
Côté frontend, le flux se fait en deux requêtes. Voici un hook personnalisé qui gère l'upload avec progression — on utilise XMLHttpRequest plutôt que fetch, car ce dernier n'expose toujours pas la progression d'envoi de manière simple :
// src/hooks/use-file-upload.ts
'use client';
import { useCallback, useState } from 'react';
type UploadState =
| { status: 'idle' }
| { status: 'uploading'; progress: number }
| { status: 'success'; key: string }
| { status: 'error'; message: string };
export function useFileUpload() {
const [state, setState] = useState<UploadState>({ status: 'idle' });
const upload = useCallback(async (file: File) => {
setState({ status: 'uploading', progress: 0 });
try {
// 1. Demander la presigned URL à l'API
const res = await fetch('/api/storage/request-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
contentLength: file.size,
}),
});
if (!res.ok) throw new Error('Demande d’upload refusée');
const { uploadUrl, key } = (await res.json()) as {
uploadUrl: string;
key: string;
};
// 2. Envoyer le fichier directement vers S3 avec progression
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
setState({ status: 'uploading', progress });
}
};
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(new Error(`Upload échoué (${xhr.status})`));
xhr.onerror = () => reject(new Error('Erreur réseau pendant l’upload'));
xhr.send(file);
});
setState({ status: 'success', key });
return key;
} catch (error) {
const message =
error instanceof Error ? error.message : 'Erreur inconnue';
setState({ status: 'error', message });
throw error;
}
}, []);
return { state, upload };
}
Et un composant d'upload minimaliste qui l'utilise :
// src/components/avatar-upload.tsx
'use client';
import { useFileUpload } from '@/hooks/use-file-upload';
export function AvatarUpload({ onUploaded }: { onUploaded: (key: string) => void }) {
const { state, upload } = useFileUpload();
return (
<div className="space-y-2">
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const key = await upload(file);
onUploaded(key);
}}
className="block w-full text-sm file:mr-4 file:rounded-lg file:border-0
file:bg-zinc-900 file:px-4 file:py-2 file:text-white"
/>
{state.status === 'uploading' && (
<div className="h-2 w-full overflow-hidden rounded-full bg-zinc-200">
<div
className="h-full bg-zinc-900 transition-all"
style={{ width: `${state.progress}%` }}
/>
</div>
)}
{state.status === 'error' && (
<p className="text-sm text-red-600">{state.message}</p>
)}
</div>
);
}
Si votre formulaire combine l'upload avec d'autres champs validés, ce hook s'intègre naturellement avec React Hook Form et Zod : on stocke la key retournée dans un champ caché du formulaire.
Confirmer l'upload et enregistrer la référence
Dernière étape souvent oubliée : votre base de données ne doit référencer que des fichiers réellement uploadés. Entre la génération de l'URL et la fin de l'upload, tout peut arriver (fermeture d'onglet, coupure réseau). Le pattern fiable : un endpoint de confirmation qui vérifie l'existence de l'objet avant d'écrire en base.
// Dans StorageService
import { HeadObjectCommand } from '@aws-sdk/client-s3';
async confirmUpload(key: string, userId: string): Promise<boolean> {
// La clé doit appartenir à l'utilisateur (préfixe uploads/{userId}/)
if (!key.startsWith(`uploads/${userId}/`)) {
return false;
}
try {
await this.s3.send(
new HeadObjectCommand({ Bucket: this.bucket, Key: key }),
);
return true; // l'objet existe bien sur S3
} catch {
return false;
}
}
Le HeadObjectCommand vérifie l'existence de l'objet sans le télécharger. Et la vérification du préfixe empêche un utilisateur de revendiquer le fichier d'un autre. Une fois confirmé, vous enregistrez la clé dans votre document Mongoose (avatar: { key: 'uploads/abc/xyz.webp' }) et vous pouvez générer des URLs de lecture signées à la demande, ou servir les fichiers publics via un CDN.
Pour les images destinées à l'affichage, pensez à brancher le domaine de votre bucket dans next.config.ts (images.remotePatterns) pour profiter de l'optimisation automatique de next/image — j'en parle en détail dans mon article sur l'optimisation des images dans Next.js 16.
Les pièges à éviter
Quelques erreurs que je croise régulièrement en audit :
Faire confiance au type MIME déclaré. Le Content-Type envoyé par le navigateur est déclaratif. Pour les cas sensibles (fichiers redistribués à d'autres utilisateurs), validez les magic bytes du fichier côté serveur après upload, avec une librairie comme file-type, via un traitement asynchrone.
Des URLs signées à durée de vie trop longue. 5 minutes suffisent pour initier un upload. Une URL valable 24 h est une porte ouverte si elle est loggée ou interceptée.
Un bucket public par défaut. Votre bucket doit être privé, avec le Block Public Access activé. Les lectures passent par des URLs signées ou un CDN devant le bucket — jamais par des objets publics en accès direct.
Oublier le CORS du bucket. Le PUT direct depuis le navigateur nécessite une configuration CORS sur le bucket autorisant votre domaine, la méthode PUT et le header Content-Type. C'est la cause n°1 des "ça marche en local mais pas en prod".
Ne jamais nettoyer les orphelins. Les uploads jamais confirmés s'accumulent. Une règle de cycle de vie S3 qui supprime les objets non confirmés après 24 h (via un préfixe pending/ déplacé à la confirmation, ou un tag) garde votre bucket propre sans code supplémentaire.
Conclusion
Les presigned URLs transforment l'upload de fichiers d'un point de fragilité en une fonctionnalité robuste et économique : votre API NestJS ne fait qu'autoriser et valider, S3 (ou R2) encaisse le transfert, et votre frontend Next.js offre une vraie progression d'upload. Les points clés à retenir : validation stricte côté serveur (type, taille, authentification), clés générées côté serveur, URLs à courte durée de vie, confirmation d'upload avant écriture en base, et configuration CORS du bucket.
Ce pattern s'adapte à tous les stockages compatibles S3 et scale de l'avatar de profil au SaaS qui traite des giga-octets quotidiens, sans changer d'architecture.
Vous avez un projet web qui implique de la gestion de fichiers — ou n'importe quelle autre fonctionnalité ? N'hésitez pas à me contacter pour en discuter, ou écrivez-moi directement à contact@alexis-mouchon.fr.