Skip to content

Upload ảnh lên Cloudinary trong Next.js: Tối giản, đủ dùng

Cloudinary là một file storage phổ biến, có gói miễn phí dùng tốt cho cá nhân và dự án nhỏ.
, chúng ta đã xây dựng xong luồng xử lý form submit trong Next.js — từ validate đến lưu dữ liệu vào database. Phần xử lý file, chúng ta dùng fs để ghi tạm vào /tmp:
if (attachment instanceof File) {
const extension = attachment.name.split('.').pop()?.toLowerCase() ?? "bin";
const fileName = `${Date.now()}.${extension}`;
const stream = fs.createWriteStream(`/tmp/${fileName}`);
const buffer = Buffer.from(await attachment.arrayBuffer());
stream.write(buffer);
stream.end();
urlItem.attachment = `/tmp/${fileName}`;
}
Cách này chạy được ở local, nhưng khi lên production thì lại là chuyện khác. /tmp là bộ nhớ tạm của server — file có thể biến mất bất cứ lúc nào, không được serve ra ngoài, và hoàn toàn không phù hợp để lưu trữ lâu dài.
Bài viết này sẽ hướng dẫn bạn thay thế phần đó bằng Cloudinary — một file storage phổ biến, có gói miễn phí dùng tốt cho cá nhân và dự án nhỏ.

Tại sao không lưu file trên server?

Nhiều bạn khi mới học sẽ nghĩ: “Lưu lên server cho tiện, khỏi phụ thuộc dịch vụ ngoài.” Nhưng trong thực tế production, cách này có khá nhiều vấn đề:
Không scale được: Nếu bạn chạy nhiều instance (horizontal scaling), file upload lên instance A sẽ không tồn tại ở instance B.
Rủi ro bảo mật: Cho phép ghi file lên server mở ra nhiều attack vector — từ path traversal đến việc upload file độc hại rồi thực thi.
Không có CDN: File phục vụ thẳng từ server, không có cache hay distribution toàn cầu.
Mất file khi redeploy: Đặc biệt với các nền tảng như Vercel hay Cloudflare, filesystem không persistent — deploy lại là mất hết.
Giải pháp chuẩn là dùng một object storage / file hosting riêng biệt như Cloudinary, AWS S3, hay Cloudflare R2. Bài này chọn Cloudinary vì API đơn giản, có SDK chính thức, và gói miễn phí đủ dùng.

Cloudinary là gì?

Cloudinary là dịch vụ lưu trữ và xử lý media (ảnh, video) trên cloud. Bạn upload ảnh lên, Cloudinary trả về một URL HTTPS — từ đó bạn lưu URL đó vào database và dùng nó ở bất cứ đâu.
Ngoài lưu trữ, Cloudinary còn cho phép transform ảnh trực tiếp qua URL — resize, crop, đổi format, nén… mà không cần xử lý phía server. Đây là điểm mạnh lớn so với chỉ dùng S3 thuần.
Gói Free của Cloudinary hỗ trợ hầu hết tính năng cần thiết. Điểm hạn chế đáng chú ý nhất là không hỗ trợ Custom Domain — ảnh của bạn sẽ được serve từ res.cloudinary.com/... thay vì domain riêng. Nếu điều này quan trọng với dự án của bạn, hãy cân nhắc Cloudflare R2 hoặc các plan trả phí.
Xem chi tiết giới hạn các gói tại:

Bước 1: Tạo tài khoản và API Key

Đăng ký tài khoản miễn phí tại . Sau khi đăng nhập, vào Settings → Access Keys để tạo một API Key mới.
⚠️ Đừng dùng API Key của root user. Hãy tạo một key mới với quyền tối thiểu cần thiết — đây là nguyên tắc least privilege phổ biến trong bảo mật.
img.jpg
img 1.jpg
Bạn sẽ có 3 thông tin cần lưu lại:
CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET
Lưu chúng vào file .dev.vars (hoặc .env.local tùy môi trường của bạn):
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret
⚠️ Đảm bảo file này đã có trong .gitignore. Không bao giờ commit secrets lên Git, và tuyệt đối không để các giá trị này xuất hiện ở phía client.

Bước 2: Cài đặt package

npm i cloudinary
Hiện tại Cloudinary đang ở v2, API có một số thay đổi so với v1 — bài này dùng v2.

Bước 3: Tạo thư viện cloudinary.ts

Tạo một file helper để khởi tạo và export hàm upload. Tách riêng ra giúp dễ tái sử dụng và kiểm tra secrets một lần duy nhất.
// lib/cloudinary.ts
import { v2 as cloudinary } from "cloudinary";
import { getCloudflareContext } from "@opennextjs/cloudflare";

const { env } = await getCloudflareContext({ async: true });

if (!env.CLOUDINARY_CLOUD_NAME) {
throw new Error("CLOUDINARY_CLOUD_NAME is not set");
}
if (!env.CLOUDINARY_API_KEY) {
throw new Error("CLOUDINARY_API_KEY is not set");
}
if (!env.CLOUDINARY_API_SECRET) {
throw new Error("CLOUDINARY_API_SECRET is not set");
}

cloudinary.config({
cloud_name: env.CLOUDINARY_CLOUD_NAME,
api_key: env.CLOUDINARY_API_KEY,
api_secret: env.CLOUDINARY_API_SECRET,
});

export async function uploadImage(image: File): Promise<string> {
// Đọc file thành binary data
const imageData = await image.arrayBuffer();

// Lấy MIME type, ví dụ "image/jpeg" hoặc "image/png"
const mime = image.type;

// Cloudinary nhận data URI dạng base64
const base64Data = Buffer.from(imageData).toString("base64");
const fileUri = `data:${mime};base64,${base64Data}`;

// Upload và trả về URL
const result = await cloudinary.uploader.upload(fileUri, {
folder: "nextjs-course-mutations",
});

return result.secure_url;
}
Một số điểm cần lưu ý trong đoạn code này:
Kiểm tra secrets trước khi config: Nếu thiếu biến môi trường, app sẽ throw ngay khi khởi động thay vì âm thầm gặp lỗi lúc runtime — dễ debug hơn nhiều.
Cloudinary SDK không nhận File object trực tiếp, nên cần chuyển sang dạng data URI trước khi upload. Vì project dùng @opennextjs/cloudflare với nodejs_compat, Buffer của Node.js hoạt động bình thường ở cả local lẫn production.
folder: Ảnh sẽ được tổ chức vào thư mục trên Cloudinary, giúp bạn dễ quản lý khi có nhiều project dùng chung một tài khoản.
secure_url: Luôn trả về URL https:// thay vì http://. Dùng cái này để lưu vào database.

Bước 4: Dùng trong Server Action

Bây giờ quay lại form action của bài trước. Phần xử lý file chỉ cần thay đúng một chỗ:
Trước (dùng fs):
if (attachment instanceof File) {
const extension = attachment.name.split('.').pop()?.toLowerCase() ?? "bin";
const fileName = `${Date.now()}.${extension}`;
const stream = fs.createWriteStream(`/tmp/${fileName}`);
Want to print your doc?
This is not the way.
Try clicking the ··· in the right corner or using a keyboard shortcut (
CtrlP
) instead.