Changer le thème

Guide complet d'upload de fichiers Next.js : upload direct S3/Qiniu via URL présignée

Easton editorial illustration: island architecture model

L’utilisateur clique sur « Téléverser l’avatar » et choisit une photo de 10 Mo. La barre de progression s’arrête à 30 %. Quarante secondes plus tard, le navigateur affiche : « Request Entity Too Large ».

Je fixais les logs Vercel et cette fameuse erreur « 4MB body size limit », pour la troisième fois. Au début, je pensais qu’une simple API Route suffirait. La réalité m’a rappelé l’ordre des choses : les fichiers grossissent, la mémoire serveur sature, les uploads ralentissent.

La solution qui tient la route : des URL présignées vers le stockage cloud. Les fichiers ne passent plus par votre serveur, ils vont directement sur S3 ou Qiniu — uploads environ 3× plus rapides, charge serveur quasi nulle, plafond qui passe de 4 Mo à 5 Go.

Cet article vous guide pas à pas : configurer S3 et Qiniu, générer des URL présignées, suivre la progression, optimiser les images, et éviter les pièges que j’ai rencontrés. Le code est prêt pour la production.

Pourquoi l’upload direct via URL présignée ?

Trois problèmes majeurs de l’approche classique

Voici le flux traditionnel : l’utilisateur choisit un fichier → envoi vers votre serveur Next.js → retransmission vers le cloud. Ça semble logique, mais en pratique ça coince.

Problème 1 : limites dures de l’API Next.js

L’App Router impose une taille de corps de requête stricte — 4 Mo par défaut. Edge Runtime est encore plus sévère : 1 Mo. Vous pourriez vouloir assouplir la config, mais Vercel et consorts ne le permettent pas. Même à 10 ou 50 Mo, une vidéo HD fera sauter la limite.

Problème 2 : le serveur ne tient pas la charge

Un fichier qui transite par le serveur double la consommation mémoire. 100 Mo reçus, puis 100 Mo renvoyés vers S3. Dix uploads simultanés ? Votre instance 2 Go explose.

J’ai lancé une communauté photo : en pointe, CPU à 90 % rien que pour le relais de fichiers. Passage à l’upload direct : 15 %. Ce n’est pas un petit gain — c’est un changement de nature.

Problème 3 : lenteur et mauvaise UX

Le détour par le serveur allonge le chemin. Utilisateur à Shenzhen, serveur en Silicon Valley, S3 à Singapour : Shenzhen → Silicon Valley → Singapour. En direct : Shenzhen → Singapour. Moitié moins de trajet, uploads nettement plus rapides.

Comment fonctionnent les URL présignées ?

En clair, c’est un « laissez-passer temporaire » du stockage cloud :

  1. L’utilisateur clique sur upload, le frontend demande au serveur Next.js une autorisation
  2. Le serveur contacte S3 : « génère-moi un lien valide 60 secondes »
  3. S3 renvoie une URL chiffrée, par ex. https://xxx.s3.amazonaws.com/file.jpg?signature=xxxx&expires=1234567890
  4. Le frontend envoie le fichier en PUT directement à S3, sans passer par votre serveur
  5. Upload terminé, S3 renvoie l’adresse finale du fichier

Les atouts de ce laissez-passer : durée limitée (expiration à 60 s), permissions minimales (un seul fichier), clés non exposées (le frontend n’accède pas à votre AWS Secret Key).

Comparaison technique

DimensionUpload classique (via serveur)Upload direct (URL présignée)
Taille max4 Mo (Vercel/Netlify)5 Go (upload S3 unique)
Mémoire serveurÉlevée (taille × 2)Nulle
CPU serveurÉlevé (relais)Minimal (génération d’URL)
VitesseLente (détour)Rapide (CDN direct)
ConcurrenceLimitée par le serveurQuasi illimitée (cloud)
SécuritéExposition partielle des identifiantsAutorisation temporaire, expiration auto
5GB
Taille maximale par URL présignée, soit 1250× la limite 4 Mo de l’API Next.js
Source: Documentation officielle AWS

La doc AWS indique qu’un upload via URL présignée accepte jusqu’à 5 Go. Au-delà, utilisez Multipart Upload — en théorie sans plafond.

S3 vs Qiniu : comment choisir ?

Principes posés, question pratique : S3 ou Qiniu ? J’ai utilisé les deux — voici leurs profils.

Tarifs : forfait annuel vs à l’usage

Qiniu mise sur des forfaits. Gratuit : 10 Go stockage + 10 Go trafic sortant par mois — suffisant pour un petit projet. Au-delà : stockage 0,148 ¥/Go/mois, trafic CDN 0,29 ¥/Go.

Exemple : 1 000 utilisateurs, 10 images chacun (2 Mo en moyenne) → 20 Go stockés, 100 Go de trafic mensuel.

  • Stockage : (20 − 10 Go gratuits) × 0,148 ¥ = 1,48 ¥
  • Trafic : (100 − 10 Go gratuits) × 0,29 ¥ = 26,1 ¥
  • Total mensuel : 27,58 ¥

AWS S3 est 100 % à l’usage. Peu de gratuit (première année pour les nouveaux comptes). Région us-east-1 : stockage 0,023 $/Go/mois, trafic 0,09 $/Go.

Même scénario :

  • Stockage : 20 Go × 0,023 $ × 7 (CNY) ≈ 3,22 ¥
  • Trafic : 100 Go × 0,09 $ × 7 ≈ 63 ¥
  • Total mensuel : 66,22 ¥

S3 paraît deux fois plus cher, mais le trafic se optimise via CloudFront, avec une couverture mondiale plus homogène.

Vitesse d’accès en Chine : un point décisif

Utilisateurs surtout en Chine ? Les nœuds CDN Qiniu sont plus denses — accès nettement plus rapide. Tests : Shenzhen → images Qiniu, 30–50 ms ; AWS S3 (même Tokyo), 120–180 ms.

Qiniu a l’hébergement ICP en Chine et des nœuds domestiques ; S3 est surtout à l’étranger. Marché international → S3 ; Chine → Qiniu.

Documentation et écosystème

La doc AWS est exhaustive mais en anglais, dense en jargon. Qiniu documente clairement en chinois avec de nombreux exemples.

Côté écosystème, S3 domine : Next.js, Vercel, librairies open source le traitent en citoyen de première classe. La communauté Qiniu est plus petite — parfois il faut creuser seul.

Mes recommandations

  • Choisir S3 si vous :

    • ciblez un public international
    • utilisez déjà Lambda, RDS, etc.
    • avez besoin de fonctions avancées (Lambda sur les images uploadées)
    • disposez du budget et privilégiez stabilité et écosystème
  • Choisir Qiniu si vous :

    • avez 90 % d’utilisateurs en Chine
    • êtes une startup au budget serré
    • voulez du support en chinois
    • exigez une forte accélération CDN

Mes projets : Qiniu pour la Chine, S3 pour l’overseas — les deux coexistent sans conflit.

Mise en œuvre S3 avec URL présignée (App Router)

Passons au code. Quatre étapes : environnement, API serveur, composant client, traitement d’images.

Étape 1 : préparation de l’environnement

Installez les packages AWS officiels :

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Puis ajoutez vos identifiants AWS dans .env.local :

AWS_REGION=ap-southeast-1  # 选离你用户最近的区域
AWS_ACCESS_KEY_ID=你的AccessKey
AWS_SECRET_ACCESS_KEY=你的SecretKey
AWS_S3_BUCKET_NAME=my-app-uploads

D’où viennent ces valeurs ? Console AWS → utilisateur IAM avec permissions minimales (upload vers un bucket donné) → Access Key. Créez le bucket S3 et choisissez une région.

N’oubliez pas le CORS ! Dans les paramètres du bucket S3, ajoutez une règle :

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT", "POST"],
    "AllowedOrigins": ["http://localhost:3000", "https://你的域名.com"],
    "ExposeHeaders": ["ETag"]
  }
]

Sans ça, le navigateur lève une erreur CORS. Je parle en connaissance de cause.

Étape 2 : génération d’URL présignée côté serveur

Créez app/api/upload/route.ts :

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { NextRequest, NextResponse } from 'next/server';

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 POST(request: NextRequest) {
  try {
    const { fileName, fileType } = await request.json();

    // 安全检查:只允许图片
    if (!fileType.startsWith('image/')) {
      return NextResponse.json(
        { error: '只支持图片格式' },
        { status: 400 }
      );
    }

    // 生成唯一文件名,避免覆盖
    const key = `uploads/${Date.now()}-${fileName}`;

    const command = new PutObjectCommand({
      Bucket: process.env.AWS_S3_BUCKET_NAME!,
      Key: key,
      ContentType: fileType,
    });

    // 生成60秒有效的预签名URL
    const uploadUrl = await getSignedUrl(s3Client, command, {
      expiresIn: 60,
    });

    // 返回上传URL和最终文件地址
    const fileUrl = `https://${process.env.AWS_S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;

    return NextResponse.json({ uploadUrl, fileUrl });
  } catch (error) {
    console.error('生成预签名URL失败:', error);
    return NextResponse.json(
      { error: '服务器错误' },
      { status: 500 }
    );
  }
}

Logique centrale :

  1. Recevoir nom et type de fichier
  2. Vérifier que c’est une image (éviter les exécutables)
  3. Générer une clé unique avec horodatage + nom d’origine
  4. Appeler getSignedUrl pour l’URL temporaire
  5. Renvoyer l’URL d’upload et l’adresse finale

Notez expiresIn: 60 — après 60 secondes l’URL expire. Vous pouvez passer à 300 (5 min), mais évitez trop long : la sécurité d’abord.

Étape 3 : composant client d’upload

Créez components/FileUpload.tsx :

'use client';

import { useState } from 'react';

export default function FileUpload() {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [fileUrl, setFileUrl] = useState('');

  const handleUpload = async () => {
    if (!file) return;

    setUploading(true);
    setProgress(0);

    try {
      // 1. 向服务器请求预签名URL
      const response = await fetch('/api/upload', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          fileName: file.name,
          fileType: file.type,
        }),
      });

      const { uploadUrl, fileUrl: finalUrl } = await response.json();

      // 2. 用XMLHttpRequest上传,可以监听进度
      await new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        xhr.upload.addEventListener('progress', (e) => {
          if (e.lengthComputable) {
            const percent = Math.round((e.loaded / e.total) * 100);
            setProgress(percent);
          }
        });

        xhr.addEventListener('load', () => {
          if (xhr.status === 200) {
            resolve(xhr.response);
          } else {
            reject(new Error('上传失败'));
          }
        });

        xhr.addEventListener('error', () => reject(new Error('网络错误')));

        xhr.open('PUT', uploadUrl);
        xhr.setRequestHeader('Content-Type', file.type);
        xhr.send(file);
      });

      setFileUrl(finalUrl);
      alert('上传成功!');
    } catch (error) {
      console.error(error);
      alert('上传失败,请重试');
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="max-w-md mx-auto p-6">
      <input
        type="file"
        accept="image/*"
        onChange={(e) => setFile(e.files?.[0] || null)}
        className="block w-full text-sm"
      />

      <button
        onClick={handleUpload}
        disabled={!file || uploading}
        className="mt-4 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        {uploading ? `上传中 ${progress}%` : '开始上传'}
      </button>

      {uploading && (
        <div className="mt-4 w-full bg-gray-200 rounded h-2">
          <div
            className="bg-blue-600 h-2 rounded transition-all"
            style={{ width: `${progress}%` }}
          />
        </div>
      )}

      {fileUrl && (
        <div className="mt-4">
          <p className="text-sm text-gray-600">上传成功!</p>
          <img src={fileUrl} alt="上传的图片" className="mt-2 max-w-full" />
        </div>
      )}
    </div>
  );
}

Pourquoi XMLHttpRequest plutôt que fetch ? Parce que fetch ne permet pas de suivre la progression de l’upload. API vieillotte, oui — mais la meilleure option ici.

Détails UX :

  • Barre de progression en temps réel
  • Bouton désactivé pendant l’upload
  • Aperçu automatique après succès

Étape 4 : traitement et optimisation des images

Les images uploadées méritent souvent une compression. Deux approches :

Approche 1 : pré-compression côté client (recommandée)

Installez une librairie :

npm install browser-image-compression

Ajoutez la compression avant l’upload :

import imageCompression from 'browser-image-compression';

const handleUpload = async () => {
  if (!file) return;

  // 压缩图片
  const options = {
    maxSizeMB: 1,          // 最大1MB
    maxWidthOrHeight: 1920, // 最大宽高1920px
    useWebWorker: true,     // 用Web Worker,不阻塞主线程
  };

  const compressedFile = await imageCompression(file, options);

  // 后续用compressedFile代替file上传
  // ...
};

Avantages : upload plus court, coûts S3 et trafic CDN réduits. Test : photo iPhone 5 Mo → 500 Ko, différence visuelle négligeable.

Approche 2 : traitement automatique côté serveur

Déclencheur Lambda S3 : à chaque upload, génération de miniatures, compression, filigrane, etc. Plus puissant, plus complexe — pour développeurs à l’aise avec AWS.

Intégration Qiniu

Même principe que S3, API différente. Points clés et écarts.

Configuration Qiniu

Inscrivez-vous sur qiniu.com, créez un espace de stockage objet. Notez :

  • AccessKey et SecretKey (centre personnel → gestion des clés)
  • Nom du bucket
  • Domaine CDN (domaine test fourni ; en prod, liez le vôtre)

Installez le SDK Node.js Qiniu :

npm install qiniu

Ajoutez la config dans .env.local :

QINIU_ACCESS_KEY=你的AccessKey
QINIU_SECRET_KEY=你的SecretKey
QINIU_BUCKET=你的Bucket名称
QINIU_DOMAIN=你的CDN域名

Génération du token d’upload côté serveur

Qiniu parle de token d’upload, pas d’URL présignée — même principe.

Créez app/api/qiniu-upload/route.ts :

import qiniu from 'qiniu';
import { NextRequest, NextResponse } from 'next/server';

const mac = new qiniu.auth.digest.Mac(
  process.env.QINIU_ACCESS_KEY!,
  process.env.QINIU_SECRET_KEY!
);

export async function POST(request: NextRequest) {
  try {
    const { fileName } = await request.json();

    // 生成唯一文件名
    const key = `uploads/${Date.now()}-${fileName}`;

    const options = {
      scope: `${process.env.QINIU_BUCKET}:${key}`,
      expires: 3600, // Token有效期1小时
      returnBody: JSON.stringify({
        key: '$(key)',
        hash: '$(etag)',
        url: `https://${process.env.QINIU_DOMAIN}/$(key)`,
      }),
    };

    const putPolicy = new qiniu.rs.PutPolicy(options);
    const uploadToken = putPolicy.uploadToken(mac);

    return NextResponse.json({
      token: uploadToken,
      key: key,
      domain: process.env.QINIU_DOMAIN,
    });
  } catch (error) {
    console.error('生成七牛云Token失败:', error);
    return NextResponse.json({ error: '服务器错误' }, { status: 500 });
  }
}

Différences avec S3 :

  • S3 renvoie une URL complète, Qiniu un token
  • S3 : 60 s d’expiration ; Qiniu : souvent 3600 s (1 h)
  • returnBody chez Qiniu définit la réponse après upload réussi

Upload client vers Qiniu

Qiniu recommande son SDK JS ; je préfère FormData, plus léger.

'use client';

import { useState } from 'react';

export default function QiniuUpload() {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [fileUrl, setFileUrl] = useState('');

  const handleUpload = async () => {
    if (!file) return;

    setUploading(true);

    try {
      // 1. 获取上传Token
      const response = await fetch('/api/qiniu-upload', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ fileName: file.name }),
      });

      const { token, key, domain } = await response.json();

      // 2. 上传到七牛云
      const formData = new FormData();
      formData.append('file', file);
      formData.append('token', token);
      formData.append('key', key);

      const uploadResponse = await fetch('https://upload.qiniup.com', {
        method: 'POST',
        body: formData,
      });

      const result = await uploadResponse.json();
      setFileUrl(`https://${domain}/${result.key}`);
      alert('上传成功!');
    } catch (error) {
      console.error(error);
      alert('上传失败');
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="max-w-md mx-auto p-6">
      <input
        type="file"
        accept="image/*"
        onChange={(e) => setFile(e.files?.[0] || null)}
        className="block w-full text-sm"
      />

      <button
        onClick={handleUpload}
        disabled={!file || uploading}
        className="mt-4 px-4 py-2 bg-green-600 text-white rounded disabled:opacity-50"
      >
        {uploading ? '上传中...' : '上传到七牛云'}
      </button>

      {fileUrl && (
        <div className="mt-4">
          <p className="text-sm text-gray-600">上传成功!</p>
          <img src={fileUrl} alt="上传的图片" className="mt-2 max-w-full" />
        </div>
      )}
    </div>
  );
}

Point d’upload Qiniu : https://upload.qiniup.com. Utilisateurs en Chine de l’Est : https://upload-z0.qiniup.com pour plus de vitesse.

Traitement d’images

Chez Qiniu, c’est plus simple que S3 : pas de Lambda, des paramètres d’URL suffisent.

URL d’origine https://xxx.com/image.jpg, miniature 300 px de large :

https://xxx.com/image.jpg?imageView2/2/w/300

Compression sous 100 Ko :

https://xxx.com/image.jpg?imageMogr2/strip/quality/75

C’est le traitement de données (fop). Qiniu propose des dizaines d’opérations combinables. S3 exige Lambda ou un service tiers — bien plus lourd.

Comparaison du code S3 vs Qiniu

ÉtapeS3Qiniu
SDK serveur@aws-sdk/client-s3qiniu
AutorisationURL présignéeToken d’upload
Point d’uploadURL du bucketupload.qiniup.com
MéthodePUT + flux fichierFormData
ImagesLambda ou tiersParamètres URL (fop)

Globalement, l’API Qiniu est plus familière pour les devs chinois, doc claire, prise en main rapide. S3 est plus puissant, courbe d’apprentissage plus raide.

Bonnes pratiques en production

Faire tourner le code en local, c’est une chose ; le stabiliser en prod, c’en est une autre. Pièges rencontrés et correctifs.

Sécurité : ne jamais fuiter les clés

Erreur classique : mettre l’AWS Secret Key dans le frontend. Des factures à plusieurs milliers de dollars suivent, quand quelqu’un abuse de vos clés.

Bonnes pratiques :

  1. Clés uniquement en variables d’environnement serveur (.env.local), jamais dans Git
  2. Rôles IAM à permissions minimales : upload S3 seulement, pas suppression ni admin
  3. Cycle de vie du bucket : supprimer les fichiers temporaires > 30 jours

Exemple IAM minimal :

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:PutObjectAcl"],
      "Resource": "arn:aws:s3:::your-bucket-name/uploads/*"
    }
  ]
}

Cette politique autorise l’upload uniquement dans uploads/, tout le reste est refusé.

Validation des fichiers indispensable avant de générer l’URL présignée :

// 白名单策略,只允许这些类型
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_SIZE = 10 * 1024 * 1024; // 10MB

if (!ALLOWED_TYPES.includes(fileType)) {
  return NextResponse.json({ error: '不支持的文件类型' }, { status: 400 });
}

if (fileSize > MAX_SIZE) {
  return NextResponse.json({ error: '文件过大' }, { status: 400 });
}

Si possible, intégrez un scan antivirus (VirusTotal, etc.) contre les fichiers malveillants.

Performance : compression client + lazy loading

En production, la compression côté client est obligatoire, pas optionnelle.

Les photos smartphone font souvent 5–10 Mo ; les envoyer telles quelles gaspille temps et bande passante. Sous 1 Mo : upload ~5× plus rapide, stockage −80 %, trafic CDN réduit — triple gain.

// 推荐的压缩配置
const compressOptions = {
  maxSizeMB: 1,
  maxWidthOrHeight: 1920,
  useWebWorker: true,
  fileType: 'image/webp', // 优先用WebP格式,体积更小
};

À l’affichage, le composant Image de Next.js gère lazy loading et responsive :

import Image from 'next/image';

<Image
  src={fileUrl}
  alt="用户上传的图片"
  width={800}
  height={600}
  loading="lazy"
  placeholder="blur"
  blurDataURL="data:image/..." // 提供模糊占位图
/>

Les images ne se chargent qu’au scroll — first paint nettement plus rapide.

UX : file d’attente + reprise

Uploads multiples ? Gérez une file et limitez la concurrence. Dix images en parallèle fige le navigateur.

Limiter la concurrence :

async function uploadQueue(files: File[], maxConcurrent = 3) {
  const results = [];
  for (let i = 0; i < files.length; i += maxConcurrent) {
    const batch = files.slice(i, i + maxConcurrent);
    const batchResults = await Promise.all(batch.map(uploadFile));
    results.push(...batchResults);
  }
  return results;
}

Reprise après interruption :

  1. Découper les gros fichiers (tranches de 5 Mo)
  2. Enregistrer la progression dans localStorage
  3. En cas d’échec ou de refresh, reprendre au point d’arrêt

Multipart Upload S3 et équivalent Qiniu existent — implémentation plus lourde. Référence : Documentation AWS Multipart Upload.

Messages d’erreur explicites :

catch (error) {
  let message = '上传失败,请重试';

  if (error.message.includes('NetworkError')) {
    message = '网络不稳定,请检查网络连接';
  } else if (error.message.includes('403')) {
    message = '上传凭证已过期,请刷新页面';
  } else if (error.message.includes('Too large')) {
    message = '文件过大,请选择小于10MB的文件';
  }

  setErrorMessage(message);
}

Ne vous contentez pas de « échec d’upload » — expliquez la cause et la marche à suivre.

Monitoring : logs et alertes

En production, loguez pour diagnostiquer.

Logs serveur :

// 生成预签名URL时记录
console.log(`[Upload] User: ${userId}, File: ${fileName}, Size: ${fileSize}`);

// 上传失败时记录详细错误
console.error(`[Upload Error]`, {
  user: userId,
  file: fileName,
  error: error.message,
  stack: error.stack,
});

Sur Vercel, les logs partent automatiquement vers leur système. Sur AWS, configurez CloudWatch :

  • Taux d’échec d’upload S3
  • Alerte e-mail si échecs > 5 %
  • Taille du bucket pour maîtriser les coûts

Monitoring frontend avec Sentry :

import * as Sentry from '@sentry/nextjs';

try {
  await uploadFile(file);
} catch (error) {
  Sentry.captureException(error, {
    tags: { feature: 'file-upload' },
    extra: { fileName, fileSize },
  });
  throw error;
}

Vous voyez combien d’utilisateurs échouent, quelles erreurs dominent — et vous optimisez en conséquence.

Dépannage des problèmes courants

Problèmes fréquents que j’ai rencontrés — ~90 % des pièges.

Problème 1 : erreur CORS — « No ‘Access-Control-Allow-Origin’ »

Symptôme : erreur rouge en console, requête bloquée.

Cause : CORS absent ou incorrect sur le bucket S3/Qiniu.

Solution :

  1. Console S3 → votre bucket
  2. « Permissions » → « CORS configuration »
  3. Collez cette config :
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST"],
    "AllowedOrigins": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

En production, évitez "*" — utilisez votre domaine, ex. ["https://yourapp.com"].

Qiniu aussi : « Paramètres CORS » dans le bucket, même logique.

Problème 2 : URL présignée invalide — 403 Forbidden

Symptôme : 403, « Access Denied » ou « Request has expired ».

Causes fréquentes :

  1. URL expirée (> 60 s ou délai configuré)
  2. Horloge serveur désynchronisée
  3. Permissions IAM insuffisantes

Solutions :

  • Horloge : vérifiez avec date ; écart > 15 min → signature invalide
  • Durée : expiresIn: 300 (5 min) pour laisser plus de marge
  • IAM : confirmez s3:PutObject et la Resource

Cas bizarre vécu : OK en local, 403 sur Vercel — instances éphémères, horloge parfois décalée. Résolu en allongeant la validité.

Problème 3 : timeout ou blocage sur gros fichiers

Symptôme : barre à 50 % puis plus rien, ou timeout.

Causes :

  1. Réseau instable
  2. Fichier trop lourd (ex. vidéo 200 Mo)
  3. Limites de timeout navigateur ou Vercel

Solutions :

  • Petits fichiers (<100 Mo) : retry côté client

    async function uploadWithRetry(url, file, maxRetries = 3) {
      for (let i = 0; i < maxRetries; i++) {
        try {
          return await upload(url, file);
        } catch (error) {
          if (i === maxRetries - 1) throw error;
          await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
        }
      }
    }
  • Gros fichiers (>100 Mo) : Multipart Upload

    • Tranches de 5 Mo
    • Upload unitaire, retry par tranche en échec
    • Fusion finale

AWS et Qiniu proposent des API de multipart — gestion des ETag et numéros de part un peu plus lourde.

Problème 4 : upload OK mais accès impossible — 403 ou 404

Symptôme : 200 à l’upload, erreur à l’ouverture de l’URL.

Causes :

  1. Bucket privé, pas de lecture publique
  2. URL mal construite
  3. Domaine CDN non configuré ou pas encore actif

Solutions :

  • S3 public : désactivez « Block all public access », ajoutez à la Bucket Policy :

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "PublicReadGetObject",
          "Effect": "Allow",
          "Principal": "*",
          "Action": "s3:GetObject",
          "Resource": "arn:aws:s3:::your-bucket-name/*"
        }
      ]
    }
  • Qiniu public : espace « public », domaine CDN lié → accès direct

  • Vérifier l’URL : loguez fileUrl, ouvrez-la dans le navigateur. 404 → bucket, région ou clé incorrects

Première fois sur S3 : j’avais oublié la Bucket Policy — une semaine de plaintes utilisateurs.

Synthèse

En une phrase : ne faites pas transiter les fichiers par votre serveur — uploadez directement vers le cloud.

URL présignée / token d’upload : contournez la limite 4 Mo de Next.js, gros fichiers, charge serveur nulle, bonne UX. S3 pour l’international, Qiniu pour la Chine — selon votre marché.

L’implémentation n’est pas complexe ; le détail fait la différence :

  • Sécurité : clés protégées, IAM minimal, validation des types
  • Performance : compression client obligatoire (−80 % stockage/trafic)
  • UX : progression, erreurs claires, file d’attente
  • Monitoring : logs et alertes pour diagnostiquer vite

Trois projets prod, plus d’un an, des millions d’uploads. Les pièges sont dans « Dépannage » — suivez la config et vous éviterez la plupart.

Le code est complet et exécutable. En cas de souci : CORS et IAM d’abord — 90 % des erreurs viennent de là.

Pistes suivantes :

  • Glisser-déposer (react-dropzone)
  • Reprise (Multipart Upload)
  • Vidéo + transcodage (S3 + AWS MediaConvert)
  • Composant upload avec barre de progression soignée

L’upload paraît simple ; le faire bien, moins. Bon courage pour votre système de niveau production.

Flux complet d'upload Next.js vers S3/Qiniu

Implémenter l'upload direct par URL présignée dans Next.js App Router, avec S3 et Qiniu

⏱️ Estimated time: 45 min

  1. 1

    Step 1: Préparation de l'environnement et dépendances

    **Solution S3** :
    • Installer le SDK AWS : npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
    • Variables : AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_BUCKET_NAME
    • Créer le bucket S3 et configurer CORS (PUT/POST)
    • IAM minimal (s3:PutObject et s3:PutObjectAcl uniquement)

    **Solution Qiniu** :
    • Installer le SDK : npm install qiniu
    • Variables : QINIU_ACCESS_KEY, QINIU_SECRET_KEY, QINIU_BUCKET, QINIU_DOMAIN
    • Créer le bucket et lier le domaine CDN
    • Configurer CORS si besoin cross-origin
  2. 2

    Step 2: API serveur

    **Génération URL présignée S3** (app/api/upload/route.ts) :
    • S3Client et PutObjectCommand
    • Validation : type (liste blanche) et taille (max 10 Mo)
    • Nom unique : uploads/horodatage-nomOriginal
    • getSignedUrl, validité 60 s
    • Retour uploadUrl et fileUrl

    **Token Qiniu** (app/api/qiniu-upload/route.ts) :
    • PutPolicy du SDK qiniu
    • scope au format Bucket:key
    • returnBody pour la réponse post-upload
    • uploadToken valide 3600 s
    • Retour token, key et domain
  3. 3

    Step 3: Composant client d'upload

    **Sélection et état** :
    • useState pour file, uploading, progress, fileUrl
    • input type="file" pour la sélection

    **Flux S3** :
    1. Appeler l'API pour l'URL présignée
    2. XMLHttpRequest (pas fetch) vers S3
    3. Écouter progress sur xhr.upload
    4. PUT avec Content-Type du fichier

    **Flux Qiniu** :
    1. Récupérer uploadToken via l'API
    2. FormData : file, token, key
    3. POST vers https://upload.qiniup.com
    4. Construire l'URL finale avec le key retourné
  4. 4

    Step 4: Compression d'images

    **Pré-compression client** (recommandé) :
    • browser-image-compression
    • maxSizeMB: 1, maxWidthOrHeight: 1920
    • Web Worker pour ne pas bloquer le thread principal
    • WebP si possible
    • Upload après compression : −80 % stockage et trafic

    **Traitement serveur** (optionnel) :
    • S3 : déclencheur Lambda pour miniatures
    • Qiniu : paramètres URL (fop), ex. ?imageView2/2/w/300
  5. 5

    Step 5: Sécurisation en production

    **Clés** :
    • Uniquement dans .env.local côté serveur, jamais dans Git
    • IAM : upload vers uploads/* seulement
    • Secret Key jamais côté frontend

    **Validation** :
    • Liste blanche image/jpeg, image/png, etc.
    • Taille max (ex. 10 Mo)
    • Option : VirusTotal

    **Coûts** :
    • Cycle de vie : suppression auto après 30 jours
    • CloudWatch sur la taille du bucket
  6. 6

    Step 6: UX et gestion des erreurs

    **Expérience upload** :
    • Progression en pourcentage
    • Bouton désactivé pendant l'upload
    • Concurrence max 3 pour multi-fichiers
    • Reprise via Multipart Upload pour gros fichiers

    **Erreurs** :
    • CORS : vérifier la config bucket
    • 403 : expiration URL, IAM, horloge serveur
    • Timeout : retry (3×) ou multipart
    • Fichier inaccessible : lecture publique et URL

    **Monitoring** :
    • Logs serveur (userId, nom, taille)
    • Sentry côté frontend
    • CloudWatch sur le taux d'échec

FAQ

Pourquoi privilégier les URL présignées plutôt que l'upload via le serveur ?
Trois avantages clés des URL présignées :

• Contourner la limite : l'API Route Next.js plafonne à 4 Mo ; une URL présignée accepte jusqu'à 5 Go
• Charge serveur nulle : upload direct vers le cloud, sans mémoire ni CPU serveur, concurrence quasi illimitée
• Plus rapide : un saut en moins, chemin plus court, vitesse multipliée par 2 à 3

Problème de l'approche classique : fichier → serveur (mémoire) → cloud (encore mémoire), double trafic, risque de saturation en pointe.
S3 ou Qiniu : lequel choisir ? Quelles différences ?
Recommandations :

**S3** : produit international, budget confortable, intégration AWS (Lambda, RDS), stabilité
**Qiniu** : utilisateurs en Chine, startup au budget serré, support en chinois, CDN performant

Différences :
• Prix : Qiniu 10 Go gratuits et ~40 % moins cher ; S3 à l'usage sans palier gratuit
• Vitesse : Chine → Qiniu 3–4× plus rapide (30 ms vs 120 ms) ; overseas → S3
• Écosystème : S3 mature ; communauté Qiniu plus petite
• Images : paramètres URL chez Qiniu ; Lambda ou tiers chez S3
Que faire en cas d'erreur CORS à l'upload ?
CORS manquant ou incorrect sur le bucket S3/Qiniu — le navigateur bloque la requête.

**S3** :
1. Console S3 → Bucket → Permissions → CORS configuration
2. AllowedMethods: ["PUT", "POST"] et AllowedOrigins: ["votre-domaine"]
3. En prod, pas de wildcard "*" — domaine explicite

**Qiniu** :
1. Paramètres bucket → CORS
2. Domaines et méthodes autorisés
3. ExposeHeaders inclut ETag

Attendre ~5 min, vider le cache, réessayer.
Pourquoi XMLHttpRequest plutôt que fetch pour uploader ?
fetch ne permet pas de suivre la progression — pas de barre en temps réel.

Atouts de XMLHttpRequest :
• xhr.upload.addEventListener('progress') pour la progression
• e.loaded et e.total pour le pourcentage
• API ancienne, mais optimale pour l'upload

Sans barre de progression, fetch suffit — UX nettement moins bonne.
La compression côté client dégrade-t-elle la qualité ?
Avec une config raisonnable, la différence est à peine visible.

Données mesurées :
• Photo iPhone 5 Mo → 500 Ko (−90 %)
• maxSizeMB: 1 et quality: 0.8
• Pas de différence notable sur mobile ou desktop

Bénéfices :
• Upload ~5× plus rapide (1 Mo vs 5 Mo)
• −80 % de coûts stockage
• Trafic CDN réduit
• Mobile plus fluide

Portfolio photo exigeant : quality 0.9 ou pas de compression.
Upload réussi mais fichier inaccessible : pourquoi ?
Trois causes fréquentes :

**Permissions bucket** (la plus courante) :
• S3 : pas de s3:GetObject ou « Block all public access » activé
• Qiniu : espace privé → passer en public

**URL mal formée** :
• Vérifier fileUrl
• S3 : https://bucket-name.s3.region.amazonaws.com/key
• Qiniu : https://cdn-domain/key

**CDN pas actif** :
• Domaine CDN Qiniu : 5–10 min de propagation
• Tester d'abord le domaine de test Qiniu

Diagnostic : ouvrir fileUrl dans le navigateur (403 = droits, 404 = chemin).
Comment uploader de gros fichiers (&gt;100 Mo) ?
Multipart Upload : découpage pour éviter les timeouts.

**Approche** :
1. Tranches de 5 Mo (Blob.slice)
2. Upload par tranche, enregistrer chaque ETag
3. Retry des tranches en échec
4. CompleteMultipartUpload pour fusionner

**API S3** :
• CreateMultipartUpload → UploadId
• UploadPart par tranche
• CompleteMultipartUpload

**Qiniu** :
• mkblk, bput, mkfile

Implémentation avancée — voir le tutoriel AWS Multipart Upload.

16 min de lecture · Publié le: 7 janv. 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog