Design wechseln

Next.js Datei-Upload: S3/Qiniu mit presigned URLs – Praxisguide

Easton editorial illustration: island architecture model

Der Nutzer klickt auf „Avatar hochladen“ und wählt ein 10-MB-Foto. Der Fortschrittsbalken erreicht 30 % – und bleibt stehen. Nach 40 Sekunden erscheint im Browser: „Request Entity Too Large“.

Ich starrte auf die Vercel-Deploy-Logs und sah wieder den vertrauten Fehler „4MB body size limit“. Zum dritten Mal fluchte ich innerlich über Next.js-API-Limits. Als ich anfing, Uploads zu bauen, dachte ich, eine API Route reiche. Die Realität lehrte mich etwas anderes: Dateien werden größer, der Server-Speicher knapp, Uploads quälend langsam.

Später fand ich eine elegantere Lösung – presigned URLs für direkten Upload in den Cloud-Speicher. Dateien laufen nicht mehr über Ihren Server, sondern gehen direkt zu S3 oder Qiniu: etwa dreimal schneller, null Serverlast, und das Limit springt von 4 MB auf 5 GB.

Dieser Artikel führt Sie Schritt für Schritt durch die Umsetzung: S3 und Qiniu konfigurieren, presigned URLs erzeugen, Fortschritt anzeigen, Bilder optimieren – und typische Fallstricke vermeiden. Der Code ist produktionsreif und direkt lauffähig.

Warum presigned URLs für Direkt-Upload?

Drei kritische Probleme klassischer Uploads

So funktioniert der klassische Weg: Nutzer wählt Datei → Upload auf Ihren Next.js-Server → Server leitet an Cloud-Speicher weiter. Klingt logisch, in der Praxis aber problematisch.

Problem 1: Harte Limits bei Next.js APIs

Der App Router begrenzt die Request-Body-Größe strikt – standardmäßig 4 MB. Edge Runtime erlaubt nur 1 MB. Konfiguration lockern? Auf Vercel und Co. oft unmöglich. Selbst 10 oder 50 MB reichen nicht für HD-Videos.

Problem 2: Der Server wird überlastet

Dateien über den Server bedeuten doppelten Speicherverbrauch: 100 MB empfangen, dann 100 MB an S3 weiterleiten. Zehn parallele Uploads? Eine 2-GB-Instanz kollabiert.

Bei einer Bild-Community lag die CPU in Spitzen bei 90 % – alles für Weiterleitung. Nach dem Wechsel auf Direkt-Upload: etwa 15 %. Kein Feintuning, ein qualitativer Sprung.

Problem 3: Langsam, schlechte UX

Der Umweg über den Server verlängert den Pfad. Nutzer in Shenzhen, Server in Silicon Valley, S3 in Singapur: Shenzhen → Silicon Valley → Singapur. Mit presigned URL: Shenzhen → Singapur – halber Weg, deutlich schneller.

Wie presigned URLs funktionieren

Kurz gesagt: eine temporäre „Eintrittskarte“ vom Cloud-Speicher. Ablauf:

  1. Nutzer startet Upload, Frontend fragt Next.js: „Ich will eine Datei hochladen“
  2. Server fragt S3: „Gib mir einen 60-Sekunden-Upload-Link“
  3. S3 liefert eine signierte URL, z. B. https://xxx.s3.amazonaws.com/file.jpg?signature=xxxx&expires=1234567890
  4. Frontend sendet die Datei per PUT direkt an S3 – ohne Ihren Server
  5. Nach Abschluss liefert S3 die finale Datei-URL

Vorteile: Zeitlimit (nach 60 s ungültig), Minimalrechte (nur diese eine Datei), kein Key-Leak (AWS Secret Key bleibt serverseitig).

Technischer Vergleich

DimensionKlassisch (über Server)Presigned-URL-Direktupload
Größenlimit4 MB (Vercel/Netlify)5 GB (S3 Einzelupload)
Server-RAMHoch (Dateigröße × 2)Null
Server-CPUHoch (Weiterleitung)Sehr niedrig (nur URL)
Upload-GeschwindigkeitLangsam (Extra-Hop)Schnell (CDN-Direkt)
ParallelitätServer-limitiertPraktisch unbegrenzt (Cloud)
SicherheitTeilweise CredentialsTemporäre Autorisierung
5GB
Maximale Dateigröße pro presigned URL – 1250× mehr als das 4-MB-Next.js-API-Limit
Source: AWS-Dokumentation

Laut AWS-Dokumentation unterstützt ein presigned URL bis 5 GB pro Upload. Größere Dateien: Multipart Upload – theoretisch ohne Obergrenze.

S3 vs. Qiniu – was wählen?

Nach der Technik folgt die praktische Frage: S3 oder Qiniu? Beide kenne ich – hier die Unterschiede.

Preise: Jahrespaket vs. Pay-as-you-go

Qiniu setzt auf Pakete. Gratis: 10 GB Speicher + 10 GB Traffic/Monat – für kleine Projekte oft genug. Darüber: Speicher ca. 0,148 ¥/GB/Monat, CDN-Traffic ca. 0,29 ¥/GB.

Beispiel: 1.000 Nutzer, je 10 Bilder à 2 MB = 20 GB Speicher, 100 GB Download/Monat:

  • Speicher: (20 − 10 GB gratis) × 0,148 ¥ ≈ 1,48 ¥
  • Traffic: (100 − 10 GB gratis) × 0,29 ¥ ≈ 26,1 ¥
  • Summe: ca. 27,58 ¥/Monat

AWS S3 ist rein nutzungsbasiert (Neukunden: begrenztes Gratis-Kontingent im ersten Jahr). us-east-1: Speicher $0,023/GB/Monat, Traffic $0,09/GB.

Gleiches Szenario:

  • Speicher: 20 GB × $0,023 × 7 ≈ 3,22 ¥
  • Traffic: 100 GB × $0,09 × 7 ≈ 63 ¥
  • Summe: ca. 66,22 ¥/Monat

S3 wirkt teurer – Traffic lässt sich aber mit CloudFront optimieren; globale Knoten sind gleichmäßiger.

Zugriff aus China – oft entscheidend

Für Nutzer in China: Qiniu mit dichteren CDN-Knoten, spürbar schneller. In Tests: Shenzhen → Qiniu ca. 30–50 ms; AWS S3 (selbst Tokio) ca. 120–180 ms.

Qiniu ist in China registriert und nutzt inländische CDN-Knoten; viele S3-Regionen liegen im Ausland. Globaler Markt → S3; China-Schwerpunkt → Qiniu.

Dokumentation und Ökosystem

AWS-Docs sind umfassend, aber englisch und terminlastig. Qiniu bietet klare chinesische Docs mit vielen Beispielen.

Ökosystem: S3 führt klar. Next.js, Vercel und Open-Source-Bibliotheken behandeln S3 als First-Class. Qiniu-Community ist kleiner.

Empfehlung

  • S3, wenn Sie:

    • ein internationales Produkt betreiben
    • bereits AWS (Lambda, RDS) nutzen
    • starke Features brauchen (z. B. Lambda für Bildverarbeitung)
    • Budget und Stabilität priorisieren
  • Qiniu, wenn Sie:

    • über 90 % Nutzer in China haben
    • als Startup Kosten sparen wollen
    • chinesischen Support bevorzugen
    • CDN-Beschleunigung wichtig ist

Meine Projekte: China-Markt → Qiniu; internationale Nutzer → S3. Beides parallel ist unproblematisch.

S3 presigned URL – Praxis (App Router)

Vier Schritte: Umgebung, Server-API, Client-Komponente, Bildverarbeitung.

Schritt 1: Umgebung vorbereiten

AWS-Pakete installieren:

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

In .env.local:

AWS_REGION=ap-southeast-1  # Region nahe Ihrer Nutzer
AWS_ACCESS_KEY_ID=你的AccessKey
AWS_SECRET_ACCESS_KEY=你的SecretKey
AWS_S3_BUCKET_NAME=my-app-uploads

IAM-Nutzer mit Minimalrechten (nur Upload in den Bucket), Access Key notieren, Bucket in S3 anlegen.

CORS nicht vergessen! Im Bucket:

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

Ohne CORS: Browser-Fehler – ich kenne das aus eigener Erfahrung.

Schritt 2: Presigned URL serverseitig erzeugen

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 }
    );
  }
}

Kernlogik:

  1. Dateiname und Typ empfangen
  2. Bild-Check (keine Executables)
  3. Eindeutiger Key mit Timestamp
  4. getSignedUrl für temporäre URL
  5. Upload-URL und finale Adresse zurückgeben

expiresIn: 60 – nach 60 s ungültig. 300 (5 Min.) möglich, aber kürzer ist sicherer.

Schritt 3: Client-Upload-Komponente

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>
  );
}

Warum XMLHttpRequest statt fetch? fetch unterstützt keinen Upload-Fortschritt. Alt, aber hier die beste Wahl.

UX-Details: Fortschrittsbalken, Button während Upload deaktiviert, Vorschau nach Erfolg.

Schritt 4: Bildverarbeitung und Optimierung

Zwei Ansätze:

Option 1: Clientseitige Vor-Komprimierung (empfohlen)

npm install browser-image-compression

Vor dem 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上传
  // ...
};

Vorteile: schnellerer Upload, weniger S3-Kosten, geringerer CDN-Traffic. iPhone-Foto 5 MB → ca. 500 KB, visuell kaum Unterschied.

Option 2: Serverseitige Verarbeitung

S3-Lambda-Trigger bei Upload: Thumbnails, Kompression, Wasserzeichen. Mächtiger, aber komplexer – für erfahrene AWS-Nutzer.

Qiniu-Integration

Gleiches Prinzip, andere API – die wichtigsten Unterschiede.

Qiniu konfigurieren

Account anlegen, Object-Storage-Bucket erstellen. Notieren:

  • AccessKey und SecretKey (Schlüsselverwaltung)
  • Bucket-Name
  • CDN-Domain (Testdomain von Qiniu; Produktion: eigene Domain)

SDK installieren:

npm install qiniu

.env.local:

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

Upload-Token serverseitig

Qiniu nennt es Upload-Token, nicht presigned URL – Prinzip gleich.

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 });
  }
}

Unterschiede zu S3:

  • S3: vollständige URL; Qiniu: Token
  • S3: oft 60 s; Qiniu: häufig 3600 s (1 h)
  • returnBody definiert die Antwort nach erfolgreichem Upload

Client-Upload zu Qiniu

FormData statt offiziellem JS-SDK – leichter:

'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>
  );
}

Upload-Endpunkt: https://upload.qiniup.com. Ostchina: https://upload-z0.qiniup.com oft schneller.

Bildverarbeitung

Qiniu ist einfacher als S3 – URL-Parameter statt Lambda.

Thumbnail 300 px Breite:

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

Kompression unter 100 KB:

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

Datenverarbeitung (fop) – Dutzende Operationen kombinierbar. Bei S3: Lambda oder Drittanbieter nötig.

Code-Vergleich S3 vs. Qiniu

SchrittS3Qiniu
Server-SDK@aws-sdk/client-s3qiniu
AutorisierungPresigned URLUpload-Token
Upload-EndpunktBucket-URLupload.qiniup.com
Upload-MethodePUT + StreamFormData
BildverarbeitungLambda/DrittanbieterURL-Parameter (fop)

Qiniu: schneller Einstieg für China-Entwickler. S3: mächtiger, steilere Lernkurve.

Best Practices für Produktion

Lauffähiger Code ≠ stabil in Produktion. Typische Fallen und Lösungen.

Sicherheit: Keys niemals leaken

Häufiger Fehler: AWS Secret Key im Frontend – Rechnungen in Tausenden Dollar.

Richtig:

  1. Keys nur in Server-.env.local, nie in Git
  2. IAM mit Minimalrechten – nur S3-Upload, kein Delete/Admin
  3. Lifecycle: temporäre Dateien nach 30 Tagen löschen

IAM Minimalrechte:

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

Nur uploads/ – alles andere verweigert.

Datei-Validierung vor presigned URL:

// 白名单策略,只允许这些类型
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 });
}

Optional: VirusTotal o. Ä. gegen Malware.

Performance: Client-Komprimierung + Lazy Loading

In Produktion Pflicht, keine Option.

Handyfotos 5–10 MB kosten Zeit und Geld. Unter 1 MB: etwa 5× schneller, ~80 % weniger Speicher und Traffic.

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

Anzeige mit Next.js Image:

import Image from 'next/image';

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

Lazy Loading: Bild lädt erst beim Scrollen – schnellerer First Paint.

UX: Upload-Warteschlange + Resume

Bei Mehrfach-Upload: Queue mit begrenzter Parallelität. 10 gleichzeitige Uploads blockieren den Browser.

Parallelität begrenzen:

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;
}

Resume (Großdateien):

  1. In 5-MB-Chunks teilen
  2. Fortschritt in localStorage
  3. Nach Fehler/Reload von Checkpoint weiter

S3 Multipart Upload und Qiniu-Äquivalent – siehe AWS Multipart Upload.

Klare Fehlermeldungen:

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);
}

Nicht nur „Upload fehlgeschlagen“ – Ursache und Lösung nennen.

Monitoring: Logs und Alarme

Server-Logs:

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

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

Vercel: Logs automatisch. AWS: CloudWatch – Fehlerrate, Speichergröße, Alarme.

Frontend: Sentry für Upload-Fehler:

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

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

So sehen Sie Häufigkeit und Fehlertypen.

Häufige Probleme

Problem 1: CORS – „No ‘Access-Control-Allow-Origin’”

Symptom: Rote Meldung in der Konsole, Request blockiert.

Ursache: Fehlende oder falsche CORS-Konfiguration im Bucket.

Lösung (S3):

  1. Bucket → Permissions → CORS configuration
  2. Konfiguration einfügen:
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST"],
    "AllowedOrigins": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

In Produktion "*" vermeiden – konkrete Domain, z. B. ["https://yourapp.com"].

Qiniu: CORS im Bucket-Setting analog.

Problem 2: Presigned URL abgelaufen – 403 Forbidden

Symptom: 403, „Access Denied“ oder „Request has expired“.

Ursachen:

  1. URL abgelaufen (> 60 s)
  2. Serverzeit falsch
  3. IAM-Rechte unzureichend

Lösung:

  • Zeit mit date prüfen (> 15 Min. Abweichung → Signatur ungültig)
  • expiresIn auf 300 erhöhen
  • IAM: s3:PutObject und korrektes Resource

Merkwürdiger Fall: lokal OK, auf Vercel 403 – Cold-Start-Zeitdrift. Längere Gültigkeit half.

Problem 3: Großdatei hängt oder Timeout

Symptom: Fortschritt bei 50 % stoppt oder Timeout.

Ursachen: instabiles Netz, Datei zu groß, Browser/Vercel-Timeout.

Lösung:

  • Klein (<100 MB): Retry-Logik

    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)));
        }
      }
    }
  • Groß (>100 MB): Multipart Upload – 5-MB-Teile, fehlgeschlagene Teile einzeln wiederholen, am Ende mergen.

Problem 4: Upload OK, Datei nicht erreichbar – 403/404

Symptom: 200 beim Upload, 403/404 beim Abruf.

Ursachen:

  1. Bucket privat, kein Public Read
  2. Falsche URL
  3. CDN noch nicht aktiv

Lösung (S3): „Block all public access“ anpassen, Bucket Policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    }
  ]
}

Qiniu: „Öffentlicher Space“, CDN-Domain binden.

Nach Upload fileUrl im Browser testen. 404 → Bucket, Region, Key prüfen.

Fazit

Kernbotschaft: Dateien nicht über Ihren Server leiten – direkt in den Cloud-Speicher.

Presigned URLs / Upload-Tokens umgehen das 4-MB-Limit, entlasten den Server, verbessern die UX. S3 für international, Qiniu für China – je nach Kontext.

Details entscheiden:

  • Sicherheit: Keys schützen, IAM minimal, Typen prüfen
  • Performance: Client-Komprimierung spart ~80 % Kosten
  • UX: Fortschritt, klare Fehler, Queue
  • Monitoring: Logs und Alarme für schnelle Diagnose

In drei Produktionsprojekten über ein Jahr stabil, Millionen Uploads. Die häufigsten Fehler stehen oben – CORS und IAM lösen etwa 90 %.

Code ist vollständig lauffähig. Bei Problemen zuerst CORS und IAM prüfen.

Als Nächstes:

  • Drag & Drop (react-dropzone)
  • Resume (Multipart Upload)
  • Video + Transcoding (S3 + AWS MediaConvert)
  • Fortschritts-UI verbessern

Upload klingt einfach – gut gemacht ist es anspruchsvoll. Dieser Guide soll Ihnen den Weg zu einem produktionsreifen System ebnen.

Next.js Datei-Upload S3/Qiniu – vollständiger Ablauf

Presigned-URL-Direktupload unter Next.js App Router – S3 und Qiniu von null bis produktionsreif

⏱️ Estimated time: 45 min

  1. 1

    Step 1: Umgebung und Abhängigkeiten

    **S3**:
    • AWS SDK: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
    • Env: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_BUCKET_NAME
    • S3-Bucket anlegen, CORS (PUT/POST)
    • IAM Minimalrechte (s3:PutObject, s3:PutObjectAcl)

    **Qiniu**:
    • SDK: npm install qiniu
    • Env: QINIU_ACCESS_KEY, QINIU_SECRET_KEY, QINIU_BUCKET, QINIU_DOMAIN
    • Bucket + CDN-Domain
    • CORS bei Cross-Origin
  2. 2

    Step 2: Server-API

    **S3 presigned URL** (app/api/upload/route.ts):
    • S3Client + PutObjectCommand
    • Validierung: Typ (Whitelist), Größe (max. 10 MB)
    • Eindeutiger Key: uploads/Timestamp-Dateiname
    • getSignedUrl, 60 s Gültigkeit
    • uploadUrl + fileUrl zurückgeben

    **Qiniu Token** (app/api/qiniu-upload/route.ts):
    • qiniu PutPolicy
    • scope: Bucket:key
    • returnBody für Erfolgsantwort
    • uploadToken, 3600 s
    • token, key, domain zurückgeben
  3. 3

    Step 3: Client-Upload-Komponente

    **State**: file, uploading, progress, fileUrl

    **S3**:
    1. API für presigned URL
    2. XMLHttpRequest (nicht fetch) für Fortschritt
    3. xhr.upload progress-Event
    4. PUT mit Content-Type

    **Qiniu**:
    1. API für uploadToken
    2. FormData: file, token, key
    3. POST an https://upload.qiniup.com
    4. key parsen, finale URL bauen
  4. 4

    Step 4: Bildkomprimierung

    **Client (empfohlen)**:
    • browser-image-compression
    • maxSizeMB: 1, maxWidthOrHeight: 1920
    • Web Worker
    • WebP bevorzugen
    • ~80 % Speicher/Traffic sparen

    **Server (optional)**:
    • S3: Lambda-Trigger für Thumbnails
    • Qiniu: fop-URL-Parameter
    • Beispiel: ?imageView2/2/w/300
  5. 5

    Step 5: Sicherheit in Produktion

    **Keys**:
    • Nur .env.local serverseitig, nie Git
    • IAM: nur uploads/*
    • Secret Key nie im Frontend

    **Validierung**:
    • Whitelist image/jpeg, png, webp, gif
    • Max. 10 MB
    • Optional VirusTotal

    **Kosten**:
    • Lifecycle: temporäre Dateien nach 30 Tagen löschen
    • CloudWatch-Alarme für Speichergröße
  6. 6

    Step 6: UX und Fehlerbehandlung

    **UX**:
    • Fortschritt in Prozent
    • Button während Upload deaktivieren
    • Max. 3 parallele Uploads
    • Resume für große Dateien (Multipart)

    **Fehler**:
    • CORS: Bucket-CORS prüfen
    • 403: URL abgelaufen, IAM, Serverzeit
    • Timeout: Retry (3×) oder Multipart
    • Nicht erreichbar: Public Read, URL prüfen

    **Monitoring**:
    • Server-Logs (User, Datei, Größe)
    • Sentry im Frontend
    • CloudWatch Fehlerrate

FAQ

Warum presigned URLs statt Upload über den Server?
Drei Kernvorteile:

• Limit umgehen: Next.js API Route standardmäßig 4 MB – presigned URL bis 5 GB
• Null Serverlast: Direktupload, kein RAM/CPU, praktisch unbegrenzte Parallelität
• Schneller: Ein Hop weniger, oft 2–3× schneller

Klassisch: Datei zum Server (RAM), Weiterleitung (nochmal RAM), doppelter Traffic, Spitzenlast riskant.
S3 oder Qiniu – was wählen?
Empfehlung:

**S3**: Internationales Produkt, Budget, AWS-Ökosystem (Lambda, RDS), Stabilität
**Qiniu**: China-Nutzer, knappes Budget, chinesischer Support, CDN in China

Unterschiede:
• Preis: Qiniu 10 GB gratis, ~40 % günstiger; S3 pay-as-you-go
• Speed: China Qiniu 3–4× schneller (30 ms vs. 120 ms); global S3
• Ökosystem: S3 führend; Qiniu kleinere Community
• Bilder: Qiniu per URL-Parameter; S3 Lambda/Drittanbieter
CORS-Fehler beim Upload – was tun?
CORS fehlt im Bucket – Browser blockiert Cross-Origin-Requests.

**S3**:
1. S3-Konsole → Bucket → Permissions → CORS
2. AllowedMethods: PUT, POST; AllowedOrigins: Ihre Domain
3. In Produktion kein * – konkrete Domain

**Qiniu**:
1. Bucket → CORS
2. Domain und Methoden erlauben
3. ExposeHeaders: ETag

5 Min. warten, Cache leeren, erneut testen.
Warum XMLHttpRequest statt fetch?
fetch unterstützt keinen Upload-Fortschritt – kein Fortschrittsbalken.

XMLHttpRequest:
• xhr.upload.addEventListener('progress')
• e.loaded / e.total für Prozent
• Alt, aber für Datei-Uploads weiterhin optimal

Ohne Fortschritt geht fetch – UX leidet (Nutzer sieht keinen Status).
Verschlechtert Client-Komprimierung die Bildqualität?
Bei sinnvollen Einstellungen: kaum sichtbar.

Messwerte:
• iPhone 5 MB → 500 KB (~90 % kleiner)
• maxSizeMB: 1, quality: 0.8
• Auf Handy/Desktop kaum Unterschied

Vorteile: 5× schnellerer Upload, ~80 % weniger Speicher/CDN, flüssiger auf Mobile.

Für Profi-Fotografie: quality 0,9 oder Komprimierung überspringen.
Upload erfolgreich, Datei nicht erreichbar – warum?
Häufigste Ursachen:

**Bucket-Berechtigung** (am häufigsten):
• S3: Kein s3:GetObject oder Block Public Access aktiv
• Qiniu: Privater Space → öffentlich

**Falsche URL**:
• S3: https://bucket.s3.region.amazonaws.com/key
• Qiniu: https://cdn-domain/key

**CDN**:
• Qiniu: 5–10 Min. nach Domain-Bindung
• Testdomain zum Verifizieren

fileUrl direkt im Browser öffnen – 403 = Rechte, 404 = Pfad.
Große Dateien (>100 MB) hochladen?
Multipart Upload – einzelner Upload timeout-anfällig.

**Ablauf**:
1. Datei in 5-MB-Teile (Blob.slice)
2. Teile hochladen, ETag pro Teil
3. Fehlgeschlagene Teile retry
4. CompleteMultipartUpload

**S3**: CreateMultipartUpload, UploadPart, CompleteMultipartUpload
**Qiniu**: mkblk, bput, mkfile

Komplex – AWS Multipart-Tutorial empfohlen.

13 Min. Lesezeit · Veröffentlicht am: 7. Jan. 2026 · Aktualisiert am: 14. Juli 2026

Ähnliche Beiträge

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog