DEV Community

Cover image for Images & Files handling in Node.js with Cloudinary
Pranav Bagal
Pranav Bagal

Posted on

Images & Files handling in Node.js with Cloudinary

Handling file uploads is a fundamental requirement for countless web applications, from social media platforms to content management systems. While it may seem straightforward to just save a file to a server's disk, this approach quickly reveals its limitations in a production environment. How do you scale storage? How do you handle backups? How do you deliver these files to users around the world with low latency?

This is where cloud-based media management services shine. Cloudinary is a market leader that provides an end-to-end solution for all your media needs, from storage and delivery to powerful on-the-fly transformations.

In this detailed guide, I'll walk you through building a robust file upload system in a Node.js, Express, and TypeScript application. I will create an API endpoint that can accept both a generic file (a PDF) and an image file (a cover image), validate them, and upload them directly to Cloudinary.

Prerequisites

To get the most out of this tutorial, you should have:

  • A basic understanding of Node.js, Express, and TypeScript.
  • A free Cloudinary account.
  • Node.js and npm (or yarn) installed on your machine.

Chapter 1: The Setup - Laying the Groundwork

First, let's get the project structure and configurations in place.

1.1: Project Initialization & Dependencies

In a new project directory, I'll start by installing the key dependencies:

  • cloudinary: The official Node.js SDK for interacting with the Cloudinary API.
  • multer: A crucial middleware for Express that handles multipart/form-data, which is used for uploading files.

I'll install them, along with TypeScript types for a better development experience.

# Install runtime dependencies
npm i multer cloudinary

# Install development dependencies for TypeScript
npm i -D @types/multer
Enter fullscreen mode Exit fullscreen mode

1.2: Securing Credentials with Environment Variables

You should never hardcode sensitive information like API keys in your source code. I'll use a .env file to store my Cloudinary credentials. Create a file named .env in your project's root directory.

You can find your Cloud Name, API Key, and API Secret in your Cloudinary Dashboard.

# .env
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret
Enter fullscreen mode Exit fullscreen mode

1.3: Configuring the Cloudinary SDK

Now, I'll create a dedicated configuration file that initializes the Cloudinary SDK with my credentials. This keeps the setup clean and centralized.

/src/config/cloudinary.ts

import { v2 as cloudinary } from "cloudinary";
// Assuming you have a utility to load env variables
import { CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET, CLOUDINARY_CLOUD_NAME } from "../utils/envLoader";

cloudinary.config({
    cloud_name: CLOUDINARY_CLOUD_NAME,
    api_key: CLOUDINARY_API_KEY,
    api_secret: CLOUDINARY_API_SECRET,
    secure: true, // Ensures that URLs are generated with HTTPS
    timeout: 60000 // Optional: request timeout in milliseconds
});

export default cloudinary;
Enter fullscreen mode Exit fullscreen mode

By exporting this configured instance, I can import it anywhere in my application to interact with the Cloudinary API.

Chapter 2: The Gatekeeper - Intercepting Files with Multer Middleware

By default, Express doesn't know how to handle file uploads sent as multipart/form-data. Multer is the middleware that bridges this gap. It parses the incoming request, extracts the files, and attaches them to the request object.

I will now create a sophisticated middleware that not only parses files but also validates them based on their purpose.

/src/middlewares/upload.middleware.ts

import multer from "multer";
import { ApiError } from "../utils/api-error"; // A custom error class

// 1. Choose a Storage Strategy
const storage = multer.memoryStorage();

// 2. Implement a Smart File Filter
const fileFilter = (field: string) => {
    return (_: any, file: Express.Multer.File, cb: any) => {
        if (field === "file") {
            // Allow PDF only for the 'file' field
            if (file.mimetype !== "application/pdf") {
                return cb(new ApiError(400, 'Only PDF files are allowed'), false);
            }
        } else if (field === "coverImage") {
            // Allow JPEG/PNG for the 'coverImage' field
            if (!['image/jpeg', 'image/png'].includes(file.mimetype)) {
                return cb(new ApiError(400, 'Only JPEG/PNG images are allowed'), false);
            }
        }
        // Accept the file
        cb(null, true);
    }
}

// 3. Configure Multer for specific fields
export const uploadFields = multer({
    storage,
    limits: { fileSize: 20 * 1024 * 1024 }, // 20MB file size limit
}).fields([
    { name: 'file', maxCount: 1 },
    { name: 'coverImage', maxCount: 1 },
]);
Enter fullscreen mode Exit fullscreen mode

Let me break this down:

  1. multer.memoryStorage(): This tells Multer to store the uploaded file as a Buffer in RAM. This is highly efficient for this use case because I can stream this buffer directly to Cloudinary without ever writing it to the server's disk.
  2. fileFilter Function: This is my custom validation logic. It's designed to check the file's mimetype and reject any file that doesn't match the rules I've set for a specific field. If validation fails, I pass a custom ApiError to the callback; otherwise, I pass null and true to accept it.
  3. multer().fields([...]): This is the final configuration. It tells Multer to expect files on specific named fields (file and coverImage). I also set a global file size limit to prevent abuse.

Chapter 3: The Core Logic - The Service Layer and Cloudinary Upload

With the middleware ready to parse and validate files, it's time to write the logic to upload them. I believe in separating this business logic into a "service" layer.

3.1: A Promisified Upload Helper

The Cloudinary SDK's upload_stream method uses a callback pattern. To work cleanly with modern async/await syntax, I'll wrap it in a Promise. This creates a reusable and elegant helper function.

/src/services/book.service.ts (Helper Function)

import cloudinary from "../config/cloudinary";

function uploadToCloudinary(buffer: Buffer, options: any): Promise<any> {
    return new Promise((resolve, reject) => {
        const stream = cloudinary.uploader.upload_stream(options, (error, result) => {
            if (error) return reject(error);
            resolve(result);
        });
        stream.end(buffer);
    });
}
Enter fullscreen mode Exit fullscreen mode

This helper takes a file buffer and Cloudinary options, returning a promise that resolves with the upload result or rejects with an error.

3.2: Orchestrating the Uploads in the Service

Now I can use the helper function inside my main service method. This method will be responsible for uploading both files to Cloudinary and then creating a record in my database.

/src/services/book.service.ts (Main Method)

// ... (imports and the helper function from above)

export class BookService {
    static async addBook(input: AddBookInput, file: any, coverImage: any) {
        const { title, author, /*...other book data*/ } = input;

        // 1. Upload file (PDF) to Cloudinary with 'raw' resource type
        const fileResult = await uploadToCloudinary(file.buffer, { resource_type: 'raw', public_id: `books/files/${title}` });

        // 2. Upload cover image to Cloudinary with 'image' resource type
        const coverResult = await uploadToCloudinary(coverImage.buffer, { resource_type: 'image', public_id: `books/covers/${title}` });

        // 3. Create book record in the database with the returned URLs
        const book = await Book.create({
            title,
            author,
            // ...other book data
            coverImageUrl: coverResult.secure_url,
            fileUrl: fileResult.secure_url
        });

        return book;
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the heart of the operation. Notice two critical details in my approach:

  1. resource_type: 'raw': When uploading the PDF, I tell Cloudinary to treat it as a generic "raw" file. This stores it as-is without trying to perform any image-specific processing.
  2. resource_type: 'image': For the cover image, I specify 'image'. This unlocks all of Cloudinary's powerful image manipulation and optimization features for this asset.
  3. public_id: This is an optional but highly recommended parameter I use. It allows you to define a specific path and name for your file in Cloudinary, making your media library much more organized.

Chapter 4: Tying It All Together - The Controller

The final piece is the controller, which handles the HTTP request and response, acting as a bridge between the client and my service layer.

4.1: The API Endpoint

In your Express router file, you would use the uploadFields middleware right before your controller function.

/src/routes/book.routes.ts

import { Router } from "express";
import { BookController } from "../controllers/book.controller";
import { uploadFields } from "../middlewares/upload.middleware";

const router = Router();

router.post('/books', uploadFields, BookController.createBook);

export default router;
Enter fullscreen mode Exit fullscreen mode

The request will flow through uploadFields first. If the files are valid, it will populate req.files and then pass control to BookController.createBook.

4.2: The Controller's Role

The controller's job is now very clean: to orchestrate the flow of data.

/src/controllers/book.controller.ts

// ... (imports for Request, Response, NextFunction, etc.)

export class BookController {
    static async createBook(req: Request, res: Response, next: NextFunction) {
        try {
            // 1. Extract JSON data from the request body
            let bookData = req.body;
            // Handle cases where JSON data is stringified in a form field
            if (typeof req.body.bookDetails === "string") {
                bookData = JSON.parse(req.body.bookDetails);
            }

            // 2. Extract files populated by Multer
            if (!req.files) throw new ApiError(400, 'File and CoverImage are required');

            const files = req.files as { [fieldname: string]: Express.Multer.File[] };
            const file = files.file?.[0];
            const cover = files.coverImage?.[0];

            if (!file || !cover) throw new ApiError(400, 'Both a book file and a cover image are required');

            // 3. Pass the data to the service layer
            const book = await BookService.addBook(bookData, file, cover);

            // 4. Send the successful response
            return res.status(201).json(new ApiResponse(201, { book }, "Book created successfully"));

        } catch (error) {
            next(error); // Pass errors to a centralized error handler
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion & Next Steps

Congratulations! You have successfully built a clean, scalable, and robust file handling system. By following the process I laid out, you've used multer for server-side parsing and validation, and Cloudinary for cloud storage and delivery to create a professional-grade solution.

This architecture provides a clear separation of concerns:

  • Middleware (multer): Handles raw request parsing and validation.
  • Controller: Manages HTTP flow and data extraction.
  • Service: Contains the core business logic of interacting with external services and the database.

From here, you can expand this foundation. I suggest:

  • Implementing Deletion: Create a service method to delete assets from Cloudinary using cloudinary.uploader.destroy() when a book record is removed from your database.
  • Using Transformations: Serve optimized images on your frontend by simply adding transformation parameters to the Cloudinary URL (e.g., resizing, cropping, adding watermarks).
  • Building the Frontend: Create a form on your client-side application that sends a FormData object to this new API endpoint.

Top comments (0)