Skip to content

Upload ảnh lên Cloudinary trong Next.js: Phiên bản nâng cao có Progress Bar

Giải pháp là "signed upload".
, chúng ta đã có một luồng upload hoạt động tốt: form submit → Server Action nhận file → upload lên Cloudinary → lưu URL vào database.
Tuy nhiên, cách đó có một giới hạn: file đi qua server của bạn trước rồi mới tới Cloudinary. Điều đó có nghĩa là bạn không thể hiển thị progress bar thực sự — vì browser chỉ thấy một request duy nhất tới server của bạn, không thấy được tiến trình upload từ server lên Cloudinary.
Bài này sẽ làm theo hướng khác: browser upload thẳng lên Cloudinary, bỏ qua server hoàn toàn ở bước upload. Nhờ đó, XMLHttpRequest.upload.onprogress sẽ cho chúng ta progress thực sự từng byte.

Vấn đề với direct upload: api_secret không thể để ở client

Cloudinary yêu cầu mọi upload đều phải được xác thực. Cách đơn giản nhất là gửi kèm api_secret — nhưng đây là thứ tuyệt đối không được để ở phía client, vì bất kỳ ai cũng có thể đọc được từ source code của browser.
Giải pháp là signed upload: server sẽ ký một bộ tham số upload với api_secret, rồi trả về chữ ký đó cho browser. Browser dùng chữ ký này để upload — không cần biết api_secret là gì.
Toàn bộ flow trông như thế này:
Server chỉ tham gia một lần duy nhất để ký. Toàn bộ bytes của file ảnh đi thẳng từ browser lên Cloudinary — server không phải xử lý hay truyền tải file.

Bước 1: Tạo signing endpoint trên server

Thêm hàm createSignedUploadParams vào file cloudinary.ts:
api_sign_request nhận vào các tham số sẽ gửi kèm lúc upload và tạo ra một chữ ký HMAC. Cloudinary sẽ dùng chính api_secret của bạn để kiểm tra chữ ký này khi nhận được upload — nếu không khớp, request bị từ chối.
Lưu ý timestamp ở đây là tính bằng giây (không phải milliseconds). Cloudinary dùng timestamp để giới hạn thời gian hiệu lực của chữ ký, tránh tình huống ai đó lưu lại signature rồi dùng mãi.
Tiếp theo, tạo Route Handler tại app/api/cloudinary/sign/route.ts:
Endpoint này không nhận tham số gì từ client — vì server tự quyết định folder và các điều kiện upload. Client không được phép tự chọn upload vào đâu.

Bước 2: Component ImagePicker

Đây là phần chính. Component này là một Client Component ('use client') vì cần tương tác trực tiếp với browser API.
Có một số state cần quản lý:
previewUrl: URL tạm từ URL.createObjectURL() để hiển thị preview ngay lập tức, trước khi upload xong.
uploadedUrl: URL thật từ Cloudinary, sẽ được gửi kèm khi form submit.
isUploading / uploadProgress: điều khiển overlay progress trên ảnh preview.
uploadError: thông báo lỗi nếu upload thất bại.
Ngoài ra cần hai ref:
activeUploadRef giữ reference đến XHR hiện tại để có thể cancel khi user chọn ảnh khác. uploadRequestIdRef là một counter tăng dần — dùng để bỏ qua kết quả từ các upload cũ khi user đã chọn ảnh mới trước khi upload trước hoàn thành (stale response).

Luồng upload

Khi user chọn file, hai việc xảy ra ngay lập tức:
URL.createObjectURL tạo một URL tạm trỏ vào file trong bộ nhớ browser — không cần upload mà ảnh đã hiển thị ngay. URL này cần được giải phóng khi không dùng nữa để tránh memory leak (xử lý ở useEffect cleanup).
Trong uploadToCloudinary:
Tại sao dùng XHR thay vì fetch? Vì fetch API hiện tại không hỗ trợ upload progress. xhr.upload.onprogress là cách duy nhất để theo dõi từng byte đi ra khỏi browser.

Overlay progress trên ảnh

Thay vì một progress bar thông thường, effect ở đây trực quan hơn: ảnh preview bị làm mờ, và một “cửa sổ” mở dần từ trái sang phải để lộ ảnh rõ nét theo đúng tỉ lệ phần trăm đã upload.
Kỹ thuật ở đây là render hai lớp ảnh chồng nhau: lớp dưới mờ (opacity 25%), lớp trên rõ nét nhưng bị cắt bởi một div có width bằng đúng uploadProgress%overflow-hidden. Khi progress tăng, div đó mở rộng dần — tạo hiệu ứng “wipe” từ trái sang phải.

Hidden input và form submit

Đây là cái kết nối toàn bộ flow với form. Khi upload hoàn tất, uploadedUrl được set bằng URL Cloudinary. Khi user submit form, Server Action nhận được URL này qua formData.get("attachment") — không cần xử lý file gì cả.

Bước 3: Kết nối với form

Trong page.tsx, component cha cần biết khi nào đang upload để disable nút submit:
onUploadingChange là callback được ImagePicker gọi mỗi khi trạng thái upload thay đổi. Nhờ đó nút submit bị disable trong suốt quá trình upload — tránh tình huống user submit form trước khi URL Cloudinary đã sẵn sàng.

So sánh hai cách upload

Bài trước (server upload)
Bài này (direct upload)
File đi qua
Server → Cloudinary
Browser → Cloudinary trực tiếp
Progress bar
❌ Không thể
✅ Từng byte thực sự
Server load
Cao hơn (xử lý file)
Thấp (chỉ ký params)
Độ phức tạp
Đơn giản hơn
Phức tạp hơn một chút
Khi nào dùng
Upload nhỏ, không cần UX progress
File lớn, cần UX tốt hơn
There are no rows in this table

Nâng cao: Tách logic upload thành custom hook

Sau khi mọi thứ hoạt động, bạn sẽ nhận ra ImagePicker đang làm quá nhiều việc cùng lúc: quản lý upload state, XHR, stale request, cancel… trong khi bản thân nó chỉ nên lo phần UI. Đây là lúc tách ra một custom hook.
Tạo file use-cloudinary-upload.ts:
'use client';

import { useEffect, useRef, useState } from "react";

type SignedUploadPayload = {
apiKey: string;
cloudName: string;
folder: string;
signature: string;
timestamp: number;
};

export function useCloudinaryUpload(onUploadingChange?: (isUploading: boolean) => void) {
const activeUploadRef = useRef<XMLHttpRequest | null>(null);
const uploadRequestIdRef = useRef(0);

const [uploadedUrl, setUploadedUrl] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);

const updateUploadingState = (isUploadingNow: boolean) => {
setIsUploading(isUploadingNow);
onUploadingChange?.(isUploadingNow);
};

const resetUploadState = () => {
setUploadedUrl("");
setUploadError(null);
setUploadProgress(0);
updateUploadingState(false);
};

const cancelUpload = () => {
uploadRequestIdRef.current += 1;
activeUploadRef.current?.abort();
activeUploadRef.current = null;
resetUploadState();
};

const uploadFile = async (file: File) => {
uploadRequestIdRef.current += 1;
const requestId = uploadRequestIdRef.current;
activeUploadRef.current?.abort();
setUploadedUrl("");
setUploadError(null);
setUploadProgress(0);
updateUploadingState(true);

try {
const signResponse = await fetch("/api/cloudinary/sign", {
method: "POST",
});

if (!signResponse.ok) {
throw new Error("Failed to get upload signature");
}

const { apiKey, cloudName, folder, signature, timestamp } =
(await signResponse.json()) as SignedUploadPayload;

const formData = new FormData();
formData.append("file", file);
formData.append("api_key", apiKey);
formData.append("folder", folder);
formData.append("signature", signature);
formData.append("timestamp", String(timestamp));

const uploadUrl = `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`;
const secureUrl = await new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
activeUploadRef.current = xhr;

xhr.open("POST", uploadUrl);

xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
setUploadProgress((event.loaded / event.total) * 100);
};

xhr.onerror = () => reject(new Error("Cloudinary upload failed"));
xhr.onabort = () => reject(new Error("Upload was cancelled"));
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error("Cloudinary upload failed"));
return;
}

const response = JSON.parse(xhr.responseText) as {
secure_url?: string;
};

if (!response.secure_url) {
reject(new Error("Cloudinary response did not include a URL"));
return;
}

resolve(response.secure_url);
};

xhr.send(formData);
});

if (requestId !== uploadRequestIdRef.current) {
return;
}

setUploadProgress(100);
setUploadedUrl(secureUrl);
} catch (error) {
if (requestId !== uploadRequestIdRef.current) {
return;
}

const message = error instanceof Error ? error.message : "Failed to upload image";
if (message !== "Upload was cancelled") {
setUploadError(message);
}
} finally {
if (requestId === uploadRequestIdRef.current) {
activeUploadRef.current = null;
updateUploadingState(false);
}
}
};

useEffect(() => {
return () => {
activeUploadRef.current?.abort();
};
}, []);

return {
cancelUpload,
isUploading,
uploadError,
uploadFile,
uploadedUrl,
Want to print your doc?
This is not the way.
Try clicking the ··· in the right corner or using a keyboard shortcut (
CtrlP
) instead.