AWS S3 upload trực tiếp từ frontend là pattern chuẩn cho file lớn. Bài này code đầy đủ Node.js backend ký URL + React frontend upload — có CORS, validate, progress bar.
Vì sao không upload qua server?
Naive design: browser POST file → Node server → forward S3. Vấn đề:
- Bandwidth gấp đôi (browser→server + server→S3)
- Block worker thread khi file lớn (Node.js streaming khó)
- Memory spike — file 500MB ngốn 500MB RAM tạm
- Timeout nginx 30s — file lớn upload fail
Pre-signed URL: server chỉ ký URL có signature, browser upload thẳng S3. Server tốn vài ms, không bandwidth, không memory.
Setup IAM permission
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:PutObjectAcl"],
"Resource": "arn:aws:s3:::alodev-uploads/uploads/*"
}]
}
Giới hạn theo prefix uploads/ — tránh write nhầm vào bucket.
CORS bucket
[
{
"AllowedOrigins": ["https://alodev.vn", "http://localhost:3000"],
"AllowedMethods": ["PUT", "POST", "GET"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
Backend ký PUT URL
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import crypto from 'node:crypto'
const s3 = new S3Client({ region: 'ap-southeast-1' })
app.post('/api/upload-url', requireAuth, async (req, res) => {
const { filename, contentType, size } = req.body
// Validate
if (size > 50 * 1024 * 1024) {
return res.status(400).json({ error: 'File too large (max 50MB)' })
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
if (!allowedTypes.includes(contentType)) {
return res.status(400).json({ error: 'File type not allowed' })
}
const key = `uploads/${req.user.id}/${crypto.randomUUID()}-${filename}`
const command = new PutObjectCommand({
Bucket: 'alodev-uploads',
Key: key,
ContentType: contentType,
ContentLength: size,
Metadata: { 'user-id': String(req.user.id) },
})
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 600 }) // 10 phút
res.json({
upload_url: uploadUrl,
file_url: `https://cdn.alodev.vn/${key}`,
key,
})
})
Frontend React upload
async function uploadFile(file: File): Promise<string> {
// Step 1: lấy presigned URL từ backend
const { upload_url, file_url } = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
}),
}).then(r => r.json())
// Step 2: upload thẳng S3 với progress
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100
console.log(`${percent.toFixed(0)}%`)
}
}
xhr.onload = () => xhr.status === 200 ? resolve(file_url) : reject(xhr.statusText)
xhr.onerror = () => reject('Network error')
xhr.open('PUT', upload_url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.send(file)
})
}
Dùng XMLHttpRequest thay fetch để có upload progress (fetch chưa support progress event ổn định).
POST policy cho điều kiện phức tạp
Khi cần force ACL, content-type phải đúng prefix (image/*), size range chính xác:
import { createPresignedPost } from '@aws-sdk/s3-presigned-post'
const { url, fields } = await createPresignedPost(s3, {
Bucket: 'alodev-uploads',
Key: `uploads/${userId}/${uuid()}-${'${filename}'}`,
Conditions: [
['content-length-range', 0, 50 * 1024 * 1024],
['starts-with', '$Content-Type', 'image/'],
['eq', '$x-amz-meta-user-id', String(userId)],
],
Fields: { 'x-amz-meta-user-id': String(userId) },
Expires: 600,
})
// Frontend submit form data với fields + file
const formData = new FormData()
Object.entries(fields).forEach(([k, v]) => formData.append(k, v))
formData.append('Content-Type', file.type)
formData.append('file', file)
await fetch(url, { method: 'POST', body: formData })
Multipart cho file lớn
File > 100MB nên dùng multipart — upload chunk song song, retry từng chunk:
// Backend: tạo multipart upload + ký URL cho mỗi part
const { UploadId } = await s3.send(new CreateMultipartUploadCommand({
Bucket, Key, ContentType: contentType,
}))
const partUrls = []
for (let i = 1; i <= partCount; i++) {
const url = await getSignedUrl(s3, new UploadPartCommand({
Bucket, Key, UploadId, PartNumber: i,
}), { expiresIn: 3600 })
partUrls.push(url)
}
return { uploadId: UploadId, partUrls }
Browser upload từng part song song, gom ETag từ response, gọi CompleteMultipartUpload. AWS SDK v3 cho browser có @aws-sdk/lib-storage handle cả flow này.
Kết luận
AWS S3 upload trực tiếp với pre-signed URL là chuẩn cho mọi app có file upload. Server tải nhẹ, scale theo S3 thay vì theo Node. Đọc CDN là gì để serve file qua CloudFront tăng tốc download cho user.