Introduction
En 2026, les applications modernes gèrent des téraoctets de données utilisateur : photos, vidéos, backups. AWS S3 reste le leader pour son scalabilité infinie, sa durabilité 99,999999999% et ses coûts optimisés. Ce tutoriel avancé vous guide pour implémenter un système Cloud Storage complet avec Next.js App Router, incluant uploads multipart (pour fichiers >5GB), presigned URLs (sécurité sans exposer credentials), versioning, lifecycle policies et intégration CloudFront CDN.
Pourquoi c'est crucial ? Sans cela, vos apps crashent sur gros volumes ou exposent des failles. Nous partons d'un projet vide vers un service production-ready, avec métadonnées en base (Prisma + PostgreSQL). Analogie : S3 est comme un océan infini où vos objets flottent, indexés par buckets. Résultat : un API REST scalable à 10k req/s. Temps estimé : 45min. (128 mots)
Prérequis
- Node.js 20+ et npm/yarn
- Compte AWS avec IAM user (S3FullAccess policy)
- Next.js 15+ et TypeScript
- Base PostgreSQL (Docker ou Supabase)
- Connaissances avancées : async/await, streams, AWS SDK v3
Initialisation du projet Next.js
npx create-next-app@latest cloud-storage-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd cloud-storage-app
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/lib-storage prisma @prisma/client
npx prisma init --datasource-provider postgresql
npm install -D @types/nodeCette commande crée un projet Next.js 15 avec App Router, installe AWS SDK v3 modulaire (client-s3 pour ops, lib-storage pour multipart, s3-request-presigner pour URLs sécurisées), Prisma pour métadonnées fichiers. Évitez les SDK legacy v2 : v3 est 40% plus rapide et tree-shakeable. Piège : Oubliez --app pour Pages Router obsolète.
Configuration AWS et Prisma
Créez un bucket S3 nommé mon-app-storage-2026 avec versioning activé et policy publique en read (pour CDN). Ajoutez lifecycle : transition vers Glacier après 30j. Configurez IAM : user avec s3:PutObject, s3:GetObject, s3:DeleteObject, bucket policy CORS pour POST, PUT, GET depuis votre domaine.
Schéma Prisma et .env
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model File {
id String @id @default(cuid())
key String @unique
bucket String
size Int
mimeType String
metadata Json?
uploadedAt DateTime @default(now())
versionId String?
userId String
@@map("files")
}Ce schéma stocke métadonnées critiques : key (S3 path), versionId pour audits. Json pour custom metadata (ex: EXIF photos). @@map évite conflits PostgreSQL. Piège : Sans size et mimeType, les downloads frontend plantent. Run npx prisma db push après.
Service S3 centralisé
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Readable } from 'stream';
const s3Client = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function uploadFile(key: string, body: Buffer | Readable, metadata: Record<string, string> = {}) {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: body,
ContentType: metadata.mimeType,
Metadata: metadata,
});
return s3Client.send(command);
}
export async function getPresignedUrl(key: string, expiresIn = 3600) {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
});
return getSignedUrl(s3Client, command, { expiresIn });
}
export async function deleteFile(key: string) {
const command = new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
});
return s3Client.send(command);
}
export async function listFiles(prefix: string) {
const command = new ListObjectsV2Command({
Bucket: process.env.S3_BUCKET!,
Prefix: prefix,
});
const { Contents } = await s3Client.send(command);
return Contents || [];
}Service modulaire avec SDK v3 : support streams/Buffer pour gros fichiers. Presigned URLs évitent exposer creds frontend. Pas de multipart ici (suivant). Piège : Toujours spécifier ContentType sinon MIME sniffé par browser = téléchargements forcés.
Uploads multipart pour gros fichiers
Pour >100MB, les single PUT échouent (limite 5GB mais timeout). Multipart divise en 5-100MB parts, parallélisable, résumable.
Upload multipart avancé
import { S3Client, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
const s3Client = new S3Client({ /* same as above */ });
export async function uploadMultipart(key: string, body: Readable, contentType: string, partSize = 10 * 1024 * 1024) {
const upload = new Upload({
client: s3Client,
params: {
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: body,
ContentType: contentType,
},
partSize,
});
upload.on('httpUploadProgress', (progress) => {
console.log({ uploaded: progress.loaded, total: progress.total });
});
const result = await upload.done();
return {
Location: result.Location,
ETag: result.ETag,
VersionId: result.VersionId,
};
}
export async function abortMultipart(uploadId: string, key: string) {
const command = new AbortMultipartUploadCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
UploadId: uploadId,
});
await s3Client.send(command);
}@aws-sdk/lib-storage gère tout : init, parts parallèles, complete/abort. partSize 10MB optimal (min 5MB). Hooks progress pour UI. Piège : Sans abort sur échec, "zombie uploads" coûtent cher (facturés/part).
API Route : Générer presigned URL upload
import { NextRequest, NextResponse } from 'next/server';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { z } from 'zod';
const s3Client = new S3Client({ /* credentials from env */ });
const schema = z.object({
mimeType: z.string().mimeType(),
size: z.number().max(5 * 1024 * 1024 * 1024),
});
export async function POST(req: NextRequest, { params }: { params: { key: string } }) {
try {
const { mimeType, size } = schema.parse(await req.json());
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: params.key,
ContentType: mimeType,
});
const url = await getSignedUrl(s3Client, command, { expiresIn: 300 });
// TODO: Save metadata to Prisma
return NextResponse.json({ url, key: params.key });
} catch (error) {
return NextResponse.json({ error: 'Validation failed' }, { status: 400 });
}
}Route dynamique /api/files/[key]/presign : valide Zod, génère PUT presigned (5min). Frontend fetch puis PUT direct S3. Piège : Sans Zod, injections MIME exploits. Intégrez Prisma ici pour log.
API Route : List et Download
import { NextRequest, NextResponse } from 'next/server';
import { listFiles, getPresignedUrl } from '@/lib/s3';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const prefix = searchParams.get('prefix') || '';
const files = await listFiles(prefix);
const signedUrls = await Promise.all(
files.slice(0, 100).map(async (file) => ({
key: file.Key!,
size: file.Size,
url: await getPresignedUrl(file.Key!),
}))
);
return NextResponse.json(signedUrls);
}
export async function DELETE(req: NextRequest) {
const { key } = await req.json();
await prisma.file.delete({ where: { key } });
await deleteFile(key);
return NextResponse.json({ success: true });
}GET liste 100 max (pagination cursor next), signed GET URLs. DELETE sync Prisma+S3. slice(0,100) anti-DoS. Piège : Sans pagination, timeout sur buckets pleins (millions objets).
Intégration CloudFront et tests
Créez distribution CloudFront sur bucket (OAC pour privé). Testez avec curl -X POST /api/files/test.jpg/presign -d '{"mimeType":"image/jpeg","size":1024}'. Frontend : useSWR pour list, fetch presign puis XMLHttpRequest PUT.
Bonnes pratiques
- Toujours presigned URLs : Zéro creds frontend, conformité GDPR.
- Multipart + Resume : partSize dynamique (chunkSize = fileSize/10000 min 5MB).
- Versioning + Lifecycle : Audits infinis, coûts <0.01$/GB/mois.
- Monitoring CloudWatch : Alarmes >80% req throttling.
- KMS Encryption : SSE-KMS pour compliance PCI/HIPAA.
Erreurs courantes à éviter
- Credentials hardcodées : Utilisez SSM Parameter Store ou Secrets Manager.
- Pas de validation size/MIME : DDoS via uploads infinis.
- Oubli abort multipart : Factures surprises (0.01$/part incomplète).
- List sans prefix/pagination : Timeouts >29s sur grands buckets.
Pour aller plus loin
- Docs AWS S3 : Developer Guide
- Avancé : S3 Select pour query JSON/CSV in-situ.
- Multi-cloud : MinIO pour on-prem.