DEV Community

Cover image for Mastering Image Uploads in Node.js: A Beginner-to-Advanced Guide with Multer and Cloudinary
Yug Jadvani
Yug Jadvani

Posted on • Edited on

1

Mastering Image Uploads in Node.js: A Beginner-to-Advanced Guide with Multer and Cloudinary

Efficiently handling image uploads is a critical aspect of backend development, particularly in modern web applications. In this comprehensive guide, we'll demonstrate how to store images using Node.js, TypeScript, PostgreSQL, Multer, and Cloudinary. Whether you're a beginner or looking to enhance your backend skills, this step-by-step tutorial will help you seamlessly implement image uploads.


Prerequisites

Before diving into the implementation, ensure you have the following:

  1. Node.js and npm/yarn installed.
  2. Basic knowledge of TypeScript and Express.js.
  3. A PostgreSQL database instance.
  4. A Cloudinary account for image hosting.

1. Initializing the Project

Start by creating a new Node.js project:

npm init -y
Enter fullscreen mode Exit fullscreen mode

Install the required dependencies:

npm install cloudinary dotenv multer pg cors express
npm install --save-dev typescript ts-node eslint nodemon typescript-eslint @eslint/js @types/express @types/multer @types/pg @types/cors
Enter fullscreen mode Exit fullscreen mode

Create a .env file to store your environment variables:

touch .env
Enter fullscreen mode Exit fullscreen mode

Populate the .env file with the following:

PORT=8080
DB_USER=your_db_user
DB_PASSWORD=your_db_password
DB_HOST=localhost
DB_PORT=5432
DB_NAME=your_db_name
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret
Enter fullscreen mode Exit fullscreen mode

2. Configuring TypeScript

Set up TypeScript by creating a tsconfig.json file:

touch tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Add the following configuration:

{
  "compilerOptions": {
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "target": "es6",
    "moduleResolution": "node",
    "sourceMap": true,
    "outDir": "dist",
    "strict": true
  },
  "lib": ["es2015"]
}
Enter fullscreen mode Exit fullscreen mode

3. Setting Up the Express Server

Create the necessary directories:

mkdir src public
cd public && mkdir temp && cd temp && touch .gitkeep
cd ../src && mkdir controllers db middlewares routes utils
Enter fullscreen mode Exit fullscreen mode

Inside the src directory, create app.ts and index.ts:

src/app.ts

import express from 'express';
import cors from 'cors';
import routes from './routes';

const app = express();

app.use(
    cors({
        origin: process.env.CORS_ORIGIN,
        credentials: true,
    })
);
app.use(express.json({ limit: '16kb' }));
app.use(express.urlencoded({ extended: true, limit: '16kb' }));
app.use(express.static('public'));

app.use('/api/v1', routes);

export default app;
Enter fullscreen mode Exit fullscreen mode

src/index.ts

import app from './app';

app.listen(process.env.PORT || 8080, () => {
  console.log(`Server running on port ${process.env.PORT}!`);
});
Enter fullscreen mode Exit fullscreen mode

4. Configuring PostgreSQL

Set up database connectivity in src/db/db.ts:

import { Pool } from 'pg';
import dotenv from 'dotenv';

// Load environment variables from .env file
dotenv.config();

const pool = new Pool({
    user: process.env.DB_USER,
    host: process.env.DB_HOST,
    database: process.env.DB_NAME,
    password: process.env.DB_PASSWORD,
    port: Number(process.env.DB_PORT),
});

pool.connect(err => {
    if (err) {
        console.error('Error connecting to the database:', err);
    } else {
        console.log('Connected to PostgreSQL database');
    }
});

export default pool;
Enter fullscreen mode Exit fullscreen mode

5. Setting Up Multer Middleware

Handle file uploads using Multer by creating src/middlewares/multer.middleware.ts:

/**
 * Multer Configuration Module
 * Configures file upload handling and storage settings using multer.
 * Files are temporarily stored in the public/temp directory before being processed.
 */

import multer from 'multer';

/**
 * Configure multer disk storage
 * Specifies destination directory and filename generation for uploaded files
 */
const storage = multer.diskStorage({
  /**
   * Set destination directory for uploaded files
   * @param req - Express request object
   * @param file - File object from multer
   * @param cb - Callback to handle destination
   */
  destination: function (
    req: Express.Request,
    file: Express.Multer.File,
    cb: (error: Error | null, destination: string) => void,
  ) {
    cb(null, './public/temp');
  },

  /**
   * Generate unique filename for uploaded file
   * Combines timestamp and random number to ensure uniqueness
   * @param req - Express request object
   * @param file - File object from multer
   * @param cb - Callback to handle filename
   */
  filename: function (
    req: Express.Request,
    file: Express.Multer.File,
    cb: (error: Error | null, filename: string) => void,
  ) {
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
    cb(null, file.fieldname + '-' + uniqueSuffix);
  },
});

/**
 * Initialize multer with configured storage
 * @example
 * // In routes file:
 * router.post('/upload', upload.single('avatar'), uploadController);
 */
const upload = multer({ storage });

export default upload;
Enter fullscreen mode Exit fullscreen mode

6. Integrating Cloudinary

Create src/utils/cloudinary.ts:

/**
 * Cloudinary Integration Module
 * This module provides functionality for uploading images to Cloudinary and managing uploads.
 */

import { v2 as cloudinary } from 'cloudinary';
import fs from 'fs';

// Initialize Cloudinary configuration immediately
(async function initCloudinary() {
  cloudinary.config({
    cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
    api_key: process.env.CLOUDINARY_API_KEY,
    api_secret: process.env.CLOUDINARY_API_SECRET,
    secure: true,
  });
})();

/** Base folder name for all uploads in this application */
export const cloudinaryFolderName = 'ryde-uber-clone';

/**
 * Uploads an image to Cloudinary and handles local file cleanup
 * @param file - Local file path of the image to upload
 * @returns Promise resolving to the Cloudinary URL of the uploaded image, or null if upload fails
 *
 * @example
 * const imageUrl = await uploadOnCloudinary('./uploads/profile.jpg');
 * if (imageUrl) {
 *   // Image uploaded successfully
 *   console.log('Image URL:', imageUrl);
 * }
 */
const uploadOnCloudinary = async (file: string): Promise<string | null> => {
  try {
    if (!file) return null;

    // Upload image to Cloudinary with specific folder and resource type
    const result = await cloudinary.uploader.upload(file, {
      folder: cloudinaryFolderName,
      resource_type: 'image',
    });

    console.log('✅ File uploaded to Cloudinary:', result.url);

    // Clean up local file after successful upload
    if (fs.existsSync(file)) {
      fs.unlinkSync(file);
      console.log('🗑️ Local file cleaned up:', file);
    } else {
      console.warn('⚠️ Local file not found for cleanup:', file);
    }

    return result.url;
  } catch (error) {
    console.error('❌ Error uploading to Cloudinary:', error);

    // Ensure local file cleanup even if upload fails
    if (fs.existsSync(file)) {
      fs.unlinkSync(file);
      console.log('🗑️ Local file cleaned up after error:', file);
    } else {
      console.warn('⚠️ Local file not found for cleanup:', file);
    }

    return null;
  }
};

export default uploadOnCloudinary;
Enter fullscreen mode Exit fullscreen mode

7. Creating Routes and Controllers

Define a route for handling uploads:

src/routes/auth.routes.ts

import express from 'express';
import { signUp } from '../controllers/auth.controllers';
import upload from '../middlewares/multer.middleware';

const router = express.Router();

router.post('/sign-up', upload.single('avatar'), signUp); // User signUp

export default router;
Enter fullscreen mode Exit fullscreen mode

src/routes/index.ts

import express from 'express';
import authRoutes from './auth.routes';

const router = express.Router();

router.use('/auth', authRoutes);

export default router;
Enter fullscreen mode Exit fullscreen mode

src/controllers/auth.controllers.ts

import { Request, Response } from 'express';
import pool from '../db/db';
import asyncHandler from '../utils/asyncHandler';
import uploadOnCloudinary, { cloudinaryFolderName } from '../utils/cloudinary';
import { v2 as cloudinary } from 'cloudinary';
import { validateRequiredFields } from '../utils/validateRequiredFields';
import { sendResponse } from '../utils/ApiResponse';
import { handleError } from '../utils/ApiError';

/**
 * Extracts and processes avatar identifier from Cloudinary URL
 * @param existingAvatarUrl - Full Cloudinary URL of the avatar
 * @returns Promise resolving to avatar identifier or null if URL is invalid
 */
const handleAvatarOperations = async (existingAvatarUrl: string | null): Promise<string | null> => {
  if (!existingAvatarUrl) return null;
  const avatarParts = existingAvatarUrl.split('ryde-uber-clone/');
  if (avatarParts.length > 1) {
    return avatarParts[1].split('.')[0];
  }
  return null;
};

/**
 * Register a new user
 * @route POST /api/auth/sign-up
 * @param {Request} req - Express request object containing user registration data
 * @param {Response} res - Express response object
 * @throws {ApiError} If registration fails
 */
export const signUp = asyncHandler(async (req: Request, res: Response): Promise<void> => {
    const { firstname, lastname, email, password } = req.body;

    if (!firstname || !lastname || !email || !password) {
        res.status(400).json({ message: 'All fields are required' });
    }

    // Check if user already exists
    const userExists = await pool.query('SELECT * FROM users WHERE email = $1', [email]);

    if (userExists.rows.length > 0) {
        res.status(409).json({
            success: false,
            message: 'User already exists',
        });
        return;
    }

    // Upload avatar
    const avatarPath = req.file?.path;

    let avatar = null;
    if (avatarPath) {
        avatar = await uploadOnCloudinary(avatarPath);
    }

    // Make sure you bcrypt the password before saving in Database
    const newUser = await pool.query(
        'INSERT INTO users (firstname, lastname, email, password, avatar) VALUES ($1, $2, $3, $4, $5) RETURNING id, email, firstname, lastname, avatar',
        [firstname, lastname, email, password, avatar]
    );

    res.status(201).json({
        success: true,
        message: 'User signed up successfully',
        user: newUser.rows[0],
    });
});

/**
 * Updates user profile information
 * @route PUT /api/users/:id
 * @body firstname - User's first name
 * @body lastname - User's last name
 * @body phone_number - User's phone number (optional)
 * @body avatar - User's profile picture (optional, file upload)
 * @access Private
 * @param req
 * @param res
 */
export const updateProfileById = async (req: Request, res: Response): Promise<void> => {
  try {
    const { id } = req.params;
    const { firstname, lastname, phone_number } = req.body;

    // Validate required fields
    const validation = validateRequiredFields({ id, firstname, lastname });
    if (!validation.isValid) {
      sendResponse(res, 400, {}, validation.error || 'Required fields are missing');
      return;
    }

    // Check if profile exists
    const existProfile = await pool.query(
      `SELECT id, avatar, firstname, lastname, email, phone_number, is_verified, created_at 
       FROM users 
       WHERE id = $1 LIMIT 1`,
      [id],
    );

    if (existProfile.rowCount === 0) {
      sendResponse(res, 404, {}, 'Profile not found');
      return;
    }

    // Handle avatar update if new file is uploaded
    const existingAvatarUrl = existProfile.rows[0].avatar;
    const existAvatar = await handleAvatarOperations(existingAvatarUrl);

    const avatarLocalPath = req.file?.path;
    let avatar = existingAvatarUrl;

    if (avatarLocalPath) {
      // Delete existing avatar from Cloudinary if it exists
      if (existAvatar) {
        await cloudinary.uploader
          .destroy(`${cloudinaryFolderName}/${existAvatar}`, { invalidate: true })
          .then((result) => console.log(result));
      }
      // Upload new avatar
      avatar = await uploadOnCloudinary(avatarLocalPath);
    }

    // Update user profile in database
    const profile = await pool.query(
      `UPDATE users 
       SET firstname = $1, lastname = $2, phone_number = $3, avatar = $4 
       WHERE id = $5 
       RETURNING id, avatar, firstname, lastname, email, phone_number, is_verified, created_at`,
      [firstname, lastname, phone_number, avatar, id],
    );

    sendResponse(res, 200, profile.rows[0], 'Profile updated successfully');
  } catch (error) {
    handleError(res, error, 'Something went wrong while updating the user details');
  }
};

/**
 * Deletes a user profile and associated resources
 * @route DELETE /api/users/:id
 * @param req
 * @param res
 */
export const deleteProfileById = async (req: Request, res: Response): Promise<void> => {
  try {
    const { id } = req.params;

    const validation = validateRequiredFields({ id });
    if (!validation.isValid) {
      sendResponse(res, 400, {}, validation.error || 'User ID is required');
      return;
    }

    // Check if profile exists
    const existProfile = await pool.query(
      `SELECT id, avatar, firstname, lastname, email, phone_number, is_verified, created_at 
       FROM users 
       WHERE id = $1 LIMIT 1`,
      [id],
    );

    if (existProfile.rowCount === 0) {
      sendResponse(res, 404, {}, 'Profile not found');
      return;
    }

    // Handle avatar deletion
    const existingAvatarUrl = existProfile.rows[0].avatar;
    const existAvatar = await handleAvatarOperations(existingAvatarUrl);

    try {
      // Delete avatar from Cloudinary if it exists
      if (existAvatar) {
        await cloudinary.uploader
          .destroy(`${cloudinaryFolderName}/${existAvatar}`, { invalidate: true })
          .then((result) => console.log(result));
      }

      // Delete user from database
      await pool.query('DELETE FROM users WHERE id = $1', [id]);
    } catch (error) {
      handleError(res, error, 'Error deleting user resources');
      return;
    }

    sendResponse(res, 200, existProfile.rows[0], 'Profile deleted successfully');
  } catch (error) {
    handleError(res, error, 'Something went wrong while deleting profile');
  }
};
Enter fullscreen mode Exit fullscreen mode

8. Testing the Application

Start the server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Use tools like Postman or curl to test the /auth/signup endpoint by sending a POST request with a file and user data.


Resources

You can find the complete code for this project on GitHub:
GitHub Repository: Image Upload with Node.js
Feel free to explore the repository, clone it, and try it out for your projects!


Conclusion

By following this guide, you've implemented a robust image upload system using Node.js, Multer, and Cloudinary. This approach ensures reliability, scalability, and ease of maintenance. Dive deeper, customize the functionality, and share your thoughts. Together, we're building better backends!


Enjoyed the read? If you found this article insightful or helpful, consider supporting my work by buying me a coffee. Your contribution helps fuel more content like this. Click here to treat me to a virtual coffee. Cheers!

Speedy emails, satisfied customers

Postmark Image

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up

Top comments (0)