Skip to content
Learni
Voir tous les tutoriels
Cloud

Comment implémenter un Cloud Storage scalable avec AWS S3 en 2026

Read in English

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

terminal
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/node

Cette 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

prisma/schema.prisma
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é

src/lib/s3.ts
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é

src/lib/s3-multipart.ts
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

src/app/api/files/[key]/presign/route.ts
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

src/app/api/files/route.ts
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.
Découvrez nos formations Learni sur AWS et Cloud.
Cloud Storage scalable AWS S3 Next.js 2026 | Learni