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

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:
- Nutzer startet Upload, Frontend fragt Next.js: „Ich will eine Datei hochladen“
- Server fragt S3: „Gib mir einen 60-Sekunden-Upload-Link“
- S3 liefert eine signierte URL, z. B.
https://xxx.s3.amazonaws.com/file.jpg?signature=xxxx&expires=1234567890 - Frontend sendet die Datei per PUT direkt an S3 – ohne Ihren Server
- 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
| Dimension | Klassisch (über Server) | Presigned-URL-Direktupload |
|---|---|---|
| Größenlimit | 4 MB (Vercel/Netlify) | 5 GB (S3 Einzelupload) |
| Server-RAM | Hoch (Dateigröße × 2) | Null |
| Server-CPU | Hoch (Weiterleitung) | Sehr niedrig (nur URL) |
| Upload-Geschwindigkeit | Langsam (Extra-Hop) | Schnell (CDN-Direkt) |
| Parallelität | Server-limitiert | Praktisch unbegrenzt (Cloud) |
| Sicherheit | Teilweise Credentials | Temporäre Autorisierung |
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:
- Dateiname und Typ empfangen
- Bild-Check (keine Executables)
- Eindeutiger Key mit Timestamp
getSignedUrlfür temporäre URL- 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)
returnBodydefiniert 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
| Schritt | S3 | Qiniu |
|---|---|---|
| Server-SDK | @aws-sdk/client-s3 | qiniu |
| Autorisierung | Presigned URL | Upload-Token |
| Upload-Endpunkt | Bucket-URL | upload.qiniup.com |
| Upload-Methode | PUT + Stream | FormData |
| Bildverarbeitung | Lambda/Drittanbieter | URL-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:
- Keys nur in Server-.env.local, nie in Git
- IAM mit Minimalrechten – nur S3-Upload, kein Delete/Admin
- 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):
- In 5-MB-Chunks teilen
- Fortschritt in localStorage
- 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):
- Bucket → Permissions → CORS configuration
- 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:
- URL abgelaufen (> 60 s)
- Serverzeit falsch
- IAM-Rechte unzureichend
Lösung:
- Zeit mit
dateprüfen (> 15 Min. Abweichung → Signatur ungültig) expiresInauf 300 erhöhen- IAM:
s3:PutObjectund 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:
- Bucket privat, kein Public Read
- Falsche URL
- 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
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
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
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
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
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
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?
• 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?
**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?
**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?
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?
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?
**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?
**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
Next.js Komplettleitfaden
Wenn du über die Suche hier gelandet bist, kommst du am schnellsten weiter, indem du zum vorherigen oder nächsten Beitrag dieser Serie springst.
Vorheriger
Next.js E-Commerce in der Praxis: Warenkorb und Stripe-Zahlung – vollständige Implementierung
Schritt-für-Schritt-Anleitung zum Aufbau eines E-Commerce-Warenkorbs und Zahlungssystems mit Zustand und Stripe – State-Management-Auswahl, Checkout Session, Webhook und Bestellverarbeitung inklusive direkt nutzbarer Codebeispiele
Teil 37 von 51
Nächster
Next.js Admin-Backend in der Praxis: RBAC-Berechtigungssystem – Leitfaden von Design bis Implementierung
Vollständiger Leitfaden zum RBAC-Berechtigungssystem für Next.js-15-Admin-Backends: Middleware-Routenschutz, dynamische Menügenerierung, shadcn/ui-Tabellenwahl und Sicherheits-Best-Practices
Teil 39 von 51
Ähnliche Beiträge
Next.js App Router Einstieg: Kernkonzepte und Grundlagen im Überblick

Next.js App Router Einstieg: Kernkonzepte und Grundlagen im Überblick
Next.js 15 in der Praxis: Wie ich an einem Wochenende ein produktionsreifes Blog-System aufgebaut habe


Kommentare
Melde dich mit GitHub an, um einen Kommentar zu hinterlassen