Add JWT Auth and Image Uploads to Comments

Add JWT authentication and Cloudinary image uploads to a MERN nested comment system.

Introduction

30 Second Summary

Comment sections are everywhere online, but most tutorials skip the hard parts like real authentication and media uploads. Adding those features is what separates a learning exercise from a portfolio-worthy project.

In this project, you will add JWT authentication and Cloudinary image uploads to your existing MERN nested comment system. This transforms it into a production-style application with secure user management and cloud-hosted media.

What You'll Build

A fully authenticated nested comment system with cloud image uploads, built on top of your existing MERN stack with RTK Query.

By the end of this project, you'll have:

  • A JWT authentication system with register/login endpoints, protected routes, and a React auth flow wired through RTK Query.
  • Cloudinary image uploads on comments using multer memory storage and upload_stream, with images stored in the cloud and URLs persisted in MongoDB.
  • A reviewed and optimized recursive comment system with annotated backend Mongoose populate logic and frontend recursive component rendering.
  • Secret Mission: Add user profile avatars uploaded via Cloudinary, displayed next to each comment.

Are there any prerequisites?

This project builds on an existing MERN nested comment system with recursive comments, likes, edit/delete functionality, and RTK Query connected to the backend. You should also have a MongoDB Atlas cluster already configured.

Before We Start

Your nested comment system already handles recursive replies, likes, and edits. But right now it has no real authentication and no way to attach images to comments.

In this step, you will install the packages that power JWT authentication and cloud image uploads. You will also create a free Cloudinary account and configure your environment variables so everything is ready for the build steps ahead.

In this step, get ready to:
  • Install four new backend packages for authentication and image uploads.
  • Create a free Cloudinary account for cloud image storage.
  • Configure environment variables for JWT and Cloudinary.
Install new backend dependencies

You need four packages to add authentication and image upload capabilities to your server.

  • In your terminal, navigate to your server directory by running this command:
cd server
  • Install the four new packages by running:
npm install jsonwebtoken bcryptjs cloudinary multer

What do these packages do?

  • jsonwebtoken creates and verifies JSON Web Tokens for user authentication.
  • bcryptjs hashes passwords so you never store them in plain text.
  • cloudinary is the Node.js SDK for uploading and managing images in the cloud.
  • multer handles multipart form data (file uploads) in Express.
  • Verify all four packages installed correctly by running:
npm ls jsonwebtoken bcryptjs cloudinary multer

You should see version numbers listed for each package with no errors.

Seeing install errors?

  • Make sure you are inside your server directory, not the project root or client folder.
  • Check that node_modules already exists in your server folder. If not, run npm install first to restore existing dependencies.
  • help me debug my npm install error
Create a free Cloudinary account

Cloudinary is a cloud service that stores and delivers images. Instead of saving uploaded files to your server's disk, you will stream them directly to Cloudinary and store the returned URL in your database.

  • Open the Cloudinary signup page in your browser:

https://cloudinary.com/users/register/free

  • Sign up using your Google account, GitHub account, or an email address. No credit card is required.
  • After signing up, you will land on the Cloudinary Console dashboard.
  • Find your Cloud Name on the dashboard under Product Environment at the top of the page.
  • Navigate to Settings in the left sidebar, then click API Keys.
  • Copy your API Key and API Secret from this page. You may need to click the reveal button and verify your password to see the API Secret.
  • Save all three values (Cloud Name, API Key, API Secret) somewhere safe. You will use them in the next substep.

Why Cloudinary instead of local file storage?

Storing uploaded images on your own server means managing disk space, serving files efficiently, and handling backups. Cloudinary handles all of this for you and provides a CDN for fast delivery.

The free tier gives you 25 credits per month with no expiration. That is more than enough for a learning project.

Can't find your API Keys page?

Cloudinary recently moved credentials out of the main dashboard. Click the Settings icon (gear) in the left sidebar, then select API Keys. Your Cloud Name, API Key, and API Secret are all listed on this page.

help me find my Cloudinary credentials

Configure your environment variables

Environment variables keep secrets out of your source code. Your server will read these values at startup without exposing them in version control.

  • In your server directory, create or open the .env file and add the following variables:
JWT_SECRET=your-jwt-secret-here-make-it-at-least-32-characters-long
CLOUDINARY_CLOUD_NAME=your-cloud-name-here
CLOUDINARY_API_KEY=your-api-key-here
CLOUDINARY_API_SECRET=your-api-secret-here

What are these variables?

  • JWT_SECRET is a random string used to sign authentication tokens. Make it at least 32 characters. You can mash your keyboard or use a password generator.
  • CLOUDINARY_CLOUD_NAME is the unique identifier for your Cloudinary product environment, found on the dashboard.
  • CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET authenticate your server with Cloudinary's upload API.
  • Replace each placeholder value with your actual credentials from the Cloudinary console.
  • Save the .env file.
  • Verify your server loads these variables by temporarily adding this log line to your main server file (e.g., server.js or index.js):
// Temporary check - remove after confirming
console.log('ENV check:', {
  JWT_SECRET: process.env.JWT_SECRET ? 'loaded' : 'missing',
  CLOUDINARY_CLOUD_NAME: process.env.CLOUDINARY_CLOUD_NAME ? 'loaded' : 'missing',
  CLOUDINARY_API_KEY: process.env.CLOUDINARY_API_KEY ? 'loaded' : 'missing',
  CLOUDINARY_API_SECRET: process.env.CLOUDINARY_API_SECRET ? 'loaded' : 'missing',
});
  • Start your server by running your usual start command (e.g., npm run dev or node server.js).

You should see all four values logged as loaded in your terminal output.

  • Remove the temporary console.log line after confirming everything is loaded.

Seeing 'missing' for one or more variables?

  • Make sure your .env file is in the server directory, not in the project root or client folder.
  • Confirm your server uses the dotenv package and calls require('dotenv').config() at the top of your entry file (before accessing process.env).
  • Check for typos in the variable names. They must match exactly: JWT_SECRET, CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET.
  • help me debug my missing environment variables

✔️ Awesome, I've got everything!

Great. Double check you have saved your .env file and removed the temporary log line.

ⓧ I'd like to double check the full code

Your .env file should look like this (with your own values replacing the placeholders):

JWT_SECRET=your-jwt-secret-here-make-it-at-least-32-characters-long
CLOUDINARY_CLOUD_NAME=your-cloud-name-here
CLOUDINARY_API_KEY=your-api-key-here
CLOUDINARY_API_SECRET=your-api-secret-here

Your development environment is ready. Next up, you will build the JWT authentication backend with a User model, register/login endpoints, and a middleware that protects your comment routes.

Build the JWT Authentication Backend

Your environment is configured and ready to go. Now it is time to build the authentication system that will protect your comment routes.

Right now, anyone can create, edit, or delete comments without proving who they are. You will fix this by creating a User model, register/login endpoints, and an auth middleware that verifies JWT tokens on every protected request.

In this step, get ready to:
  • Create a User model with password hashing and token generation.
  • Build register and login API endpoints.
  • Create an auth middleware and apply it to all comment routes.
Create the User model

Every authentication system needs a way to store user credentials. You will create a Mongoose model that defines the user schema, automatically hashes passwords before saving, and generates signed tokens on demand.

  • Create a new file called User.js inside your server/models directory.
  • Add the schema definition by pasting this code into server/models/User.js:
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');

// Define the user schema with required fields
const userSchema = new mongoose.Schema({
  username: {
    type: String,
    required: true,
    unique: true,
    trim: true,
  },
  email: {
    type: String,
    required: true,
    unique: true,
    trim: true,
    lowercase: true,
  },
  password: {
    type: String,
    required: true,
    minlength: 6,
  },
}, { timestamps: true });

What does this code do?

This defines a Mongoose schema with three fields: username, email, and password. Each field has validation rules built in.

The unique: true constraint prevents duplicate usernames or emails. The timestamps: true option automatically adds createdAt and updatedAt fields to every document.

  • Add the pre-save hook below the schema definition to automatically hash passwords:
// Hash the password before saving to the database
userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

What is a pre-save hook?

A pre-save hook is middleware that runs automatically before a document is saved to MongoDB. This hook checks if the password field was modified. If it was, it generates a salt and hashes the plain-text password using bcryptjs with 10 rounds.

The isModified('password') check prevents re-hashing an already hashed password when the user updates other fields like their username.

  • Add the instance methods and the model export below the pre-save hook:
// Generate a signed JWT with the user's ID as payload
userSchema.methods.generateAuthToken = function () {
  const token = jwt.sign({ _id: this._id }, process.env.JWT_SECRET, {
    expiresIn: '7d',
  });
  return token;
};

// Compare a plain-text password against the stored hash
userSchema.methods.comparePassword = async function (candidatePassword) {
  return bcrypt.compare(candidatePassword, this.password);
};

module.exports = mongoose.model('User', userSchema);

What do these methods do?

The generateAuthToken() method creates a JWT containing the user's _id as the payload. The token expires after 7 days, so users stay logged in for a week before needing to re-authenticate.

The comparePassword() method uses bcrypt.compare() to safely check a login attempt against the stored hash without ever decrypting the original password.

  • Save server/models/User.js.

Seeing a syntax error on save?

Make sure all three code chunks are in the correct order: imports and schema first, then the pre-save hook, then the methods and export. A misplaced closing bracket or missing comma in the schema fields will cause a parse error.

help me fix a syntax error in my User.js model

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');

const userSchema = new mongoose.Schema({
  username: {
    type: String,
    required: true,
    unique: true,
    trim: true,
  },
  email: {
    type: String,
    required: true,
    unique: true,
    trim: true,
    lowercase: true,
  },
  password: {
    type: String,
    required: true,
    minlength: 6,
  },
}, { timestamps: true });

userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

userSchema.methods.generateAuthToken = function () {
  const token = jwt.sign({ _id: this._id }, process.env.JWT_SECRET, {
    expiresIn: '7d',
  });
  return token;
};

userSchema.methods.comparePassword = async function (candidatePassword) {
  return bcrypt.compare(candidatePassword, this.password);
};

module.exports = mongoose.model('User', userSchema);
Create the auth routes

With the User model in place, you need endpoints that let users register and log in. Each endpoint validates the request, interacts with the User model, and returns a signed JWT on success.

  • Create a new file called auth.js inside your server/routes directory.
  • Add the register endpoint by pasting this code into server/routes/auth.js:
const express = require('express');
const router = express.Router();
const User = require('../models/User');

// POST /register - Create a new user account
router.post('/register', async (req, res) => {
  try {
    const { username, email, password } = req.body;

    // Validate that all fields are present
    if (!username || !email || !password) {
      return res.status(400).json({ message: 'All fields are required' });
    }

    // Check if user already exists by email or username
    const existingUser = await User.findOne({ $or: [{ email }, { username }] });
    if (existingUser) {
      return res.status(400).json({ message: 'User already exists' });
    }

    // Create the user (password is hashed by the pre-save hook)
    const user = new User({ username, email, password });
    await user.save();

    // Generate a token and return user info
    const token = user.generateAuthToken();
    res.status(201).json({ token, user: { _id: user._id, username: user.username, email: user.email } });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
});

What does the register endpoint do?

The register route validates that all fields are provided, checks the database for duplicates using a compound $or query, then creates and saves the user. The pre-save hook you wrote earlier automatically hashes the password before it hits the database.

On success, it calls generateAuthToken() and returns both the token and a safe subset of user data (no password field in the response).

  • Add the login endpoint and the router export below the register route:
// POST /login - Authenticate an existing user
router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;

    // Validate that all fields are present
    if (!email || !password) {
      return res.status(400).json({ message: 'All fields are required' });
    }

    // Find the user by email
    const user = await User.findOne({ email });
    if (!user) {
      return res.status(400).json({ message: 'Invalid credentials' });
    }

    // Compare the provided password with the stored hash
    const isMatch = await user.comparePassword(password);
    if (!isMatch) {
      return res.status(400).json({ message: 'Invalid credentials' });
    }

    // Generate a token and return user info
    const token = user.generateAuthToken();
    res.json({ token, user: { _id: user._id, username: user.username, email: user.email } });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
});

module.exports = router;

Why use the same error message for both checks?

The login route returns Invalid credentials whether the email is wrong or the password is wrong. This is intentional. Giving different messages for "email not found" vs "wrong password" tells attackers which emails exist in your database.

  • Save server/routes/auth.js.
  • Register this router in your main server file (e.g., server.js or index.js) by adding:
// Mount auth routes
const authRoutes = require('./routes/auth');
app.use('/api/auth', authRoutes);
  • Save your server file.

Server crashing after adding the route?

Make sure the require('./routes/auth') path matches where you saved the file. Also confirm the app.use line is placed after your JSON body parser middleware (app.use(express.json())) but before any catch-all error handlers.

help me fix my server crash after adding auth routes

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

const express = require('express');
const router = express.Router();
const User = require('../models/User');

router.post('/register', async (req, res) => {
  try {
    const { username, email, password } = req.body;

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

    const existingUser = await User.findOne({ $or: [{ email }, { username }] });
    if (existingUser) {
      return res.status(400).json({ message: 'User already exists' });
    }

    const user = new User({ username, email, password });
    await user.save();

    const token = user.generateAuthToken();
    res.status(201).json({ token, user: { _id: user._id, username: user.username, email: user.email } });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
});

router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;

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

    const user = await User.findOne({ email });
    if (!user) {
      return res.status(400).json({ message: 'Invalid credentials' });
    }

    const isMatch = await user.comparePassword(password);
    if (!isMatch) {
      return res.status(400).json({ message: 'Invalid credentials' });
    }

    const token = user.generateAuthToken();
    res.json({ token, user: { _id: user._id, username: user.username, email: user.email } });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
});

module.exports = router;
Create the auth middleware and protect your routes

The register and login endpoints hand out tokens. Now you need a middleware function that checks incoming requests for a valid token before allowing access to comment operations.

  • Create a new file called auth.js inside your server/middleware directory.
  • Add the auth middleware by pasting this code into server/middleware/auth.js:
const jwt = require('jsonwebtoken');

// Middleware that verifies the JWT from the Authorization header
const auth = (req, res, next) => {
  const authHeader = req.header('Authorization');
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: 'No token, authorization denied' });
  }

  // Extract the token (everything after "Bearer ")
  const token = authHeader.replace('Bearer ', '');

  try {
    // Verify the token and attach the decoded payload to the request
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    res.status(401).json({ message: 'Token is not valid' });
  }
};

module.exports = auth;

How does this middleware protect routes?

When this middleware runs before a route handler, it extracts the token from the Authorization: Bearer <token> header. It then verifies the token signature using your JWT_SECRET. If verification succeeds, the decoded payload (containing the user's _id) is attached to req.user so downstream route handlers know who is making the request.

If the token is missing or invalid, the middleware immediately returns a 401 response and never calls next(). The route handler never executes.

  • Save server/middleware/auth.js.
  • Open your existing comment routes file (e.g., server/routes/comments.js).
  • Import the auth middleware at the top of the file:
const auth = require('../middleware/auth');
  • Add auth as a middleware parameter to each of your comment route handlers (create, edit, delete, like). For example, change router.post('/', async (req, res) => { to:
// Add auth middleware before the route handler
router.post('/', auth, async (req, res) => {
  • Apply the same pattern to your edit, delete, and like routes by inserting auth between the route path and the handler function.
  • In your comment creation route handler, update the author field to use the authenticated user's ID from the token:
// Set the comment author from the verified token
author: req.user._id

Why set author from the token?

Previously, the author might have been sent in the request body, which means anyone could impersonate another user. By reading the author from req.user._id (set by the auth middleware), you guarantee the comment is attributed to whoever owns the token.

  • Save your comment routes file.

Now test the full auth flow. You will register a user, log in, and hit a protected route with and without the token.

  • Make sure your server is running.
  • Register a test user by running this command in your terminal:
curl -X POST http://localhost:5000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username": "testuser", "email": "test@example.com", "password": "password123"}'
  • You should see a JSON response containing a token field and a user object with _id, username, and email.
  • Copy the token value from the response. Log in with the same user to verify the login endpoint works:
curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "password123"}'
  • You should see the same response shape: a token and user object.
  • Test a protected comment route without a token to confirm it returns 401:
curl -X POST http://localhost:5000/api/comments \
  -H "Content-Type: application/json" \
  -d '{"text": "This should fail"}'
  • You should see: {"message":"No token, authorization denied"} with a 401 status.

Not getting a 401 on the unprotected request?

Double check that you added auth as a middleware parameter in your comment route. It should appear between the route path string and the handler function, like router.post('/', auth, async (req, res) => {. If you placed it after the handler or forgot to import it, the middleware will not run.

help me debug why my protected route is not returning 401 without a token

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

const jwt = require('jsonwebtoken');

const auth = (req, res, next) => {
  const authHeader = req.header('Authorization');
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: 'No token, authorization denied' });
  }

  const token = authHeader.replace('Bearer ', '');

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    res.status(401).json({ message: 'Token is not valid' });
  }
};

module.exports = auth;

Your authentication backend is complete. Users can register, log in, and receive tokens. Every comment route now requires a valid JWT. Next up, you will wire this auth system into your React frontend so users can register and log in from the browser.

Build the JWT Authentication Frontend

Your backend can now register users, issue tokens, and protect routes. The missing piece is the React frontend that lets users actually interact with this system.

In this step, you will create RTK Query mutations for login and register, a Redux Toolkit slice to manage auth state, and the UI components that tie everything together.

In this step, get ready to:
  • Create an RTK Query auth API slice and a Redux auth state slice.
  • Build Login and Register page components with forms.
  • Configure automatic token attachment and route protection.
Create the auth API and state slices

RTK Query handles data fetching and caching. You will create an API slice specifically for authentication endpoints, and a separate Redux slice that stores the token and user info in memory.

  • Create a new file at client/src/features/auth/authApi.js.
  • Add the following RTK Query API slice with register and login mutations:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const authApi = createApi({
  reducerPath: 'authApi',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api/auth',
  }),
  endpoints: (builder) => ({
    register: builder.mutation({
      query: (credentials) => ({
        url: '/register',
        method: 'POST',
        body: credentials,
      }),
    }),
    login: builder.mutation({
      query: (credentials) => ({
        url: '/login',
        method: 'POST',
        body: credentials,
      }),
    }),
  }),
});

export const { useRegisterMutation, useLoginMutation } = authApi;

What does this code do?

createApi generates React hooks for each endpoint automatically. The register and login mutations correspond to your backend POST endpoints at /api/auth/register and /api/auth/login.

The exported hooks (useRegisterMutation and useLoginMutation) give your components a trigger function plus loading/error state for free.

  • Save authApi.js.

Seeing an import error?

Make sure your import path includes @reduxjs/toolkit/query/react (not just @reduxjs/toolkit/query). The /react entry point is required for the auto-generated hooks to work.

Help me fix my RTK Query import

Now you need a Redux slice that holds the authentication state. This slice will persist the token across page refreshes using localStorage.

  • Create a new file at client/src/features/auth/authSlice.js.
  • Add the following auth state slice:
import { createSlice } from '@reduxjs/toolkit';

const token = localStorage.getItem('token');
const user = localStorage.getItem('user');

const initialState = {
  token: token || null,
  user: user ? JSON.parse(user) : null,
  isAuthenticated: !!token,
};

const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    setCredentials: (state, action) => {
      state.token = action.payload.token;
      state.user = action.payload.user;
      state.isAuthenticated = true;
      localStorage.setItem('token', action.payload.token);
      localStorage.setItem('user', JSON.stringify(action.payload.user));
    },
    logout: (state) => {
      state.token = null;
      state.user = null;
      state.isAuthenticated = false;
      localStorage.removeItem('token');
      localStorage.removeItem('user');
    },
  },
});

export const { setCredentials, logout } = authSlice.actions;
export default authSlice.reducer;

What does this code do?

The slice reads from localStorage on initialization, so a page refresh does not log the user out. The isAuthenticated flag is derived from whether a token exists.

setCredentials saves the token and user to both Redux state and localStorage. The logout action clears both, giving you a clean signout flow.

  • Save authSlice.js.
  • Confirm your file structure now has both client/src/features/auth/authApi.js and client/src/features/auth/authSlice.js.

Seeing a JSON parse error on load?

If localStorage.getItem('user') returns a malformed string, JSON.parse will throw. Clear your browser's localStorage for this site and reload.

Help me debug my localStorage initialization

Next, register both the authApi reducer and the authSlice reducer in your Redux store configuration. Add authApi.middleware to the middleware array as well.

  • Open your Redux store file (typically client/src/app/store.js or similar).
  • Import authApi from features/auth/authApi and the default export from features/auth/authSlice.
  • Add [authApi.reducerPath]: authApi.reducer and auth: authSliceReducer to your store's reducer map.
  • Concatenate authApi.middleware into the middleware array.
  • Save your store file.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const authApi = createApi({
  reducerPath: 'authApi',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api/auth',
  }),
  endpoints: (builder) => ({
    register: builder.mutation({
      query: (credentials) => ({
        url: '/register',
        method: 'POST',
        body: credentials,
      }),
    }),
    login: builder.mutation({
      query: (credentials) => ({
        url: '/login',
        method: 'POST',
        body: credentials,
      }),
    }),
  }),
});

export const { useRegisterMutation, useLoginMutation } = authApi;
import { createSlice } from '@reduxjs/toolkit';

const token = localStorage.getItem('token');
const user = localStorage.getItem('user');

const initialState = {
  token: token || null,
  user: user ? JSON.parse(user) : null,
  isAuthenticated: !!token,
};

const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    setCredentials: (state, action) => {
      state.token = action.payload.token;
      state.user = action.payload.user;
      state.isAuthenticated = true;
      localStorage.setItem('token', action.payload.token);
      localStorage.setItem('user', JSON.stringify(action.payload.user));
    },
    logout: (state) => {
      state.token = null;
      state.user = null;
      state.isAuthenticated = false;
      localStorage.removeItem('token');
      localStorage.removeItem('user');
    },
  },
});

export const { setCredentials, logout } = authSlice.actions;
export default authSlice.reducer;
Build Login and Register page components

With the API and state slices ready, you need UI components that let users register and log in. Each component is a form that calls the RTK Query mutation, then dispatches setCredentials to store the returned token.

  • Create a Register component (e.g. client/src/pages/Register.js or client/src/components/Register.js).
  • Add form fields for username, email, and password.
  • Import useRegisterMutation from your authApi slice.
  • On form submit, call the register mutation with the form data.
  • Import setCredentials from authSlice and dispatch it with the response (which contains token and user).
  • After dispatching, navigate the user to the comments page using your router (e.g. useNavigate() from React Router).
  • Create a Login component following the same pattern.
  • Use useLoginMutation instead of the register mutation.
  • The Login form only needs email and password fields (the backend login route does not require username).
  • Display error messages from the mutation's error state if login or register fails.
  • Add both components as routes in your React Router configuration.

Why dispatch setCredentials after the mutation?

The RTK Query mutation handles the network request, but it does not automatically store the response in your auth state. Dispatching setCredentials is what persists the token to Redux state and localStorage, making it available for future requests.

  • Save your Login and Register component files.
  • Navigate to your Register route in the browser. You should see the three form fields rendered.

Form not rendering?

Check that you added the new route to your React Router configuration. Also confirm the component is exported correctly (default export if your router expects it).

Help me debug my auth form component

Configure token attachment and route protection

Your comment routes on the backend expect a Bearer token in the Authorization header. You need to configure your existing comments API's base query to attach the JWT automatically on every request.

  • Open your existing RTK Query API file for comments (the one with your comment CRUD endpoints).
  • Locate the fetchBaseQuery configuration.
  • Add a prepareHeaders function to the fetchBaseQuery options.
  • Inside prepareHeaders, use getState() to read state.auth.token from your Redux store.
  • If a token exists, call headers.set('authorization', `Bearer ${token}`) to attach it.
  • Return the headers object.

What is prepareHeaders?

prepareHeaders is a hook that RTK Query calls before every request. It receives the headers object and a destructured { getState } so you can read from your Redux store. This means you write the auth header logic once and it applies to every API call automatically.

Finally, you need to protect your comment routes on the frontend so unauthenticated users get redirected to the login page.

  • Create a route guard component (e.g. ProtectedRoute.js or RequireAuth.js).
  • Inside this component, read isAuthenticated from your auth state using useSelector.
  • If isAuthenticated is false, redirect to the login page using Navigate from React Router.
  • If authenticated, render the child route (using Outlet or children props).
  • Wrap your comment page routes with this guard component in your router configuration.
  • Save all modified files.

Add a logout button

Add a logout button somewhere in your navigation that dispatches the logout action. This clears the token from both Redux and localStorage, and the route guard will redirect to login.

Now test the full authentication flow end-to-end.

  • Navigate to your Register page and create a new user.
  • After registration, confirm you are redirected to the comments page and can create a comment.
  • Refresh the page. Confirm you remain logged in (the token persists via localStorage).
  • Click your logout button. Confirm you are redirected to the login page.
  • Try navigating directly to the comments page. Confirm the route guard redirects you back to login.

Getting a 401 even after logging in?

Open your browser's DevTools Network tab and check the Authorization header on comment API requests. It should read Bearer <your-token>. If the header is missing, confirm your prepareHeaders function is reading from the correct state path (state.auth.token).

Also verify that your store has the auth reducer registered under the key auth (not authSlice or another name).

Help me debug my JWT token not being sent in requests

Your comment system now has real authentication. Users register, log in, and get a JWT that travels with every request. Next up, you will add Cloudinary image uploads so users can attach images to their comments.

Integrate Cloudinary Image Uploads

Your comment system now has real authentication. Users can register, log in, and post comments securely. But comments are still text-only.

In this step, you'll build an image upload pipeline using Cloudinary and multer. Images go from the browser, through your Express server's memory, and up to Cloudinary's cloud storage via a stream. The returned URL gets saved alongside the comment in MongoDB.

In this step, get ready to:
  • Create the Cloudinary config and multer upload middleware.
  • Build a stream-based upload helper and wire it into your comment creation route.
  • Add an image picker to your frontend comment form with preview and display.
Create the Cloudinary config and upload middleware

The upload pipeline needs two pieces of infrastructure. First, a configuration file that connects your server to your Cloudinary account. Second, a middleware that intercepts file uploads and holds them in memory before they leave your server.

  • Create a new folder called config inside your server directory.
  • Create a file called cloudinary.js inside server/config/ and add the following code:
const cloudinary = require('cloudinary').v2;

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

module.exports = cloudinary;

What does this code do?

This file imports the Cloudinary SDK's v2 API and calls cloudinary.config() with the three environment variables you added to .env in Step 1.

By exporting the configured instance, any file that requires this module gets a ready-to-use Cloudinary client without needing to re-configure.

  • Save server/config/cloudinary.js.

Now you need the middleware that catches uploaded files before they hit your route handler. multer with memory storage keeps the file in a buffer. No temporary files are written to disk.

  • Create a file called upload.js inside server/middleware/ and add the following code:
const multer = require('multer');

const storage = multer.memoryStorage();

const fileFilter = (req, file, cb) => {
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
  if (allowedTypes.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new Error('Invalid file type. Only JPEG, PNG, GIF, and WebP are allowed.'), false);
  }
};

const upload = multer({
  storage,
  fileFilter,
  limits: { fileSize: 5 * 1024 * 1024 },
});

module.exports = upload;

What does this code do?

multer.memoryStorage() tells multer to hold the uploaded file as a Buffer in req.file.buffer instead of saving it to disk. This is ideal for streaming directly to Cloudinary.

The fileFilter rejects anything that is not a JPEG, PNG, GIF, or WebP. The limits option caps uploads at 5 MB to prevent oversized files from consuming server memory.

  • Save server/middleware/upload.js.
  • Confirm that both files exist by checking your server folder structure. You should see config/cloudinary.js and middleware/upload.js listed alongside your existing middleware/auth.js.

Getting a require error on startup?

Make sure you installed the cloudinary and multer packages in Step 1. If either is missing, run npm install cloudinary multer again inside your server directory.

Help me debug a require error in my Cloudinary config

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for both new files:

const cloudinary = require('cloudinary').v2;

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

module.exports = cloudinary;
const multer = require('multer');

const storage = multer.memoryStorage();

const fileFilter = (req, file, cb) => {
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
  if (allowedTypes.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new Error('Invalid file type. Only JPEG, PNG, GIF, and WebP are allowed.'), false);
  }
};

const upload = multer({
  storage,
  fileFilter,
  limits: { fileSize: 5 * 1024 * 1024 },
});

module.exports = upload;
Build the upload helper and update your comment route

The config connects to Cloudinary. The middleware catches files. Now you need the piece in between: a helper that takes the file buffer from multer and streams it up to Cloudinary, returning the hosted URL.

  • Create a new folder called utils inside your server directory.
  • Create a file called cloudinaryUpload.js inside server/utils/ and add the following code:
const cloudinary = require('../config/cloudinary');

const uploadToCloudinary = (fileBuffer) => {
  return new Promise((resolve, reject) => {
    const stream = cloudinary.uploader.upload_stream(
      { folder: 'comments', resource_type: 'image' },
      (error, result) => {
        if (error) return reject(error);
        resolve(result);
      }
    );
    stream.end(fileBuffer);
  });
};

module.exports = uploadToCloudinary;

What does this code do?

cloudinary.uploader.upload_stream() returns a writable stream. Instead of saving the file to disk and passing a file path, you pipe the in-memory buffer directly to Cloudinary by calling stream.end(fileBuffer).

The folder: 'comments' option organizes all uploaded comment images into a dedicated folder in your Cloudinary dashboard. When the upload completes, the callback receives a result object containing secure_url (the HTTPS link to the hosted image).

Why use upload_stream instead of saving to disk?

Writing temporary files creates cleanup headaches and disk I/O overhead. The memory-to-stream approach keeps the file entirely in RAM and sends it to Cloudinary in one pass. This is the pattern Cloudinary recommends for Express apps.

  • Save server/utils/cloudinaryUpload.js.

Now you need to connect these pieces to your existing comment system. This involves two changes: adding an imageUrl field to your Comment model, and updating your comment creation route to handle file uploads.

  • Open your Comment model file (likely server/models/Comment.js or similar).
  • Add an imageUrl field to your schema with type String and a default of an empty string.
  • Open your comment routes file (where your POST route for creating comments lives).
  • Import the upload middleware: require('../middleware/upload').
  • Import the upload helper: require('../utils/cloudinaryUpload').
  • Chain upload.single('image') as middleware on your comment creation route, before your auth middleware.
  • Inside your route handler, check if req.file exists. If it does, call uploadToCloudinary(req.file.buffer) and save the returned result.secure_url to the comment's imageUrl field.
  • Save your updated Comment model and comment routes files.

Why does multer come before auth in the middleware chain?

When the frontend sends a FormData request (multipart/form-data), the JWT token is still in the Authorization header. Multer needs to parse the multipart body first so that any text fields are available on req.body and the file is on req.file. Your auth middleware only reads the header, so the order works cleanly.

  • Restart your server to pick up the new files.
  • Test by sending a multipart POST request (using Postman, Thunder Client, or curl) to your comment creation endpoint with an image file attached. You should receive a response containing a Cloudinary URL in the imageUrl field.

Getting a 500 error on upload?

Check that your .env file has the correct CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET values from your Cloudinary dashboard. Also confirm the form field name in your request matches image (what multer expects).

Help me debug my Cloudinary upload returning a 500 error

✔️ Awesome, I've got everything!

Great. Make sure you have saved all modified files before moving on.

ⓧ I'd like to double check the full code

Here is the upload helper file in full:

const cloudinary = require('../config/cloudinary');

const uploadToCloudinary = (fileBuffer) => {
  return new Promise((resolve, reject) => {
    const stream = cloudinary.uploader.upload_stream(
      { folder: 'comments', resource_type: 'image' },
      (error, result) => {
        if (error) return reject(error);
        resolve(result);
      }
    );
    stream.end(fileBuffer);
  });
};

module.exports = uploadToCloudinary;
Add image upload to the frontend comment form

Your backend can now accept and store images. The frontend needs three updates: a file input for picking images, a preview so users see what they selected, and a switch from JSON to FormData when submitting a comment with an attachment.

  • Open your comment form component (the React component where users type and submit comments).
  • Add a piece of state to hold the selected file (e.g. const [imageFile, setImageFile] = useState(null)).
  • Add an <input type="file" accept="image/*" /> element to your form. Wire its onChange handler to store the selected file in state.
  • Add an image preview below the file input. When a file is selected, call URL.createObjectURL(file) to generate a temporary local URL and display it in an <img> tag.
  • Update your form's submit handler. When an image file is present, build a FormData object and append both the text content and the image file to it. Send the FormData as the request body instead of a JSON object.
  • When no image is selected, continue sending the comment as JSON (your existing behavior).

Why switch between JSON and FormData?

Browsers cannot send binary file data inside a JSON body. FormData encodes the request as multipart/form-data, which supports both text fields and file attachments in a single request. When there is no image, plain JSON keeps things simple.

Finally, update your comment display component to show images when they exist.

  • Open your comment display component (the one that renders each comment).
  • Add a conditional render: if the comment's imageUrl field is not empty, render an <img> tag with src={comment.imageUrl}.
  • Save all modified frontend files.
  • Refresh your app in the browser.
  • Create a comment with an image attached. You should see the image preview appear before you submit, and the uploaded image displayed in the comment after submission.
  • Open your Cloudinary dashboard. Navigate to **Media Library**. You should see a comments folder containing your uploaded image.
  • Create another comment without an image to confirm text-only comments still work.

Image not appearing after submission?

Check that your RTK Query mutation for creating comments does not force a Content-Type: application/json header when sending FormData. The browser sets the correct multipart header automatically when you pass FormData as the body. Explicitly setting the header overrides it and breaks the upload.

Also confirm the field name you append to FormData matches image (e.g. formData.append('image', file)). This must match what multer expects from upload.single('image').

Help me debug why my image upload is not working from the frontend

Your comment system now supports image uploads through a clean pipeline: multer catches the file in memory, the helper streams it to Cloudinary, and the returned URL lives in MongoDB. Next up, you will review how the recursive nested comment logic works on both the backend and frontend, and apply optimizations.

Review and Optimize the Recursive Comment System

You now have authentication and image uploads working end-to-end. The last piece is the recursive comment logic that ties everything together.

Recursive data patterns are powerful but easy to implement inefficiently. A poorly structured populate chain can fire dozens of database queries for a single page load. In this step, you will review both your backend and frontend recursive logic, annotate how the data flows, and apply targeted optimizations.

In this step, get ready to:
  • Review and annotate the backend recursive populate logic.
  • Review the frontend recursive component and apply React.memo() if needed.
  • Test the full end-to-end flow with 3 levels of nested comments.
Review and annotate the backend recursive populate logic

Your comment system already loads nested replies using Mongoose .populate(). The goal here is to understand exactly how data flows from a single GET request to the fully nested response, and to ensure you are not accidentally firing N+1 queries.

  • Open your comment routes file (likely routes/comments.js or similar) in VS Code.
  • Find the GET route that returns all top-level comments.
  • Identify how .populate() is called on the children (or replies) field. Determine whether your code uses a single deep populate, a recursive populate function, or multiple separate queries.

What is recursive populate?

Mongoose's .populate() replaces a stored ObjectId reference with the actual document. A recursive populate calls .populate() on the same field within each populated child, building the full tree in a single logical operation.

If your current code loops through comments and fires a separate query per child, you have an N+1 problem. Each level of nesting multiplies the number of database round-trips.

  • If your code already uses a single recursive populate (a populate function that references itself on the children path), confirm it works by adding a comment above it documenting the data flow.
  • If your code uses multiple separate queries (one query per nesting level, or a loop that fetches children individually), refactor it to use a single query with a recursive populate function.

How does a recursive populate function work?

The pattern defines a populate object where the populate option inside the object references the same structure recursively. Mongoose resolves this by populating each nested level until it reaches documents with no children.

Your recursive populate object should reference the children (or replies) path and include a nested populate property pointing back to itself. This way, a single call resolves the entire tree.

  • Add inline comments to your GET route documenting: (1) the initial query that fetches top-level comments, (2) the recursive populate that loads all nested children, and (3) the response shape the frontend receives.
  • Save your comment routes file.

Check for author population too

While reviewing your populate chain, confirm that the author field is also populated at every nesting level with username. This ensures the frontend can display who wrote each reply without extra queries.

Review the frontend recursive component and apply React.memo()

On the frontend, your comment component renders itself recursively for each child comment. This is elegant but can cause unnecessary re-renders if props are not scoped tightly.

  • Open your recursive comment component file in VS Code (likely Comment.js or CommentItem.js in your components folder).
  • Locate where the component maps over its children (or replies) array and renders the same component for each child.
  • Confirm that the key prop on each rendered child uses the comment's _id field. If it uses an array index, change it to _id now.

Why does the key prop matter for recursive components?

React uses the key prop to identify which items changed between renders. Using array indices as keys in a recursive tree causes React to re-mount entire subtrees when a comment is added or removed anywhere in the chain.

Using the stable _id from MongoDB means React can precisely track each comment node, even as the tree grows or shrinks.

  • Check what props the recursive comment component receives. It should receive only the data it needs (the individual comment object), not entire state slices or the full comments array.
  • If the component receives extra props or you notice it re-renders when unrelated comments change, wrap the component export with React.memo().

What does React.memo() do here?

React.memo() wraps a component and prevents it from re-rendering unless its props actually change. In a recursive tree with dozens of comment nodes, this stops a single "like" action from triggering re-renders on every visible comment.

Apply it by wrapping your component's export: export default React.memo(CommentItem). If your component already re-renders only when its own data changes, you can skip this optimization.

  • Add inline comments to your recursive component documenting: (1) where the recursion happens (the map over children), (2) the base case (a comment with no children renders without the map), and (3) how props flow at each level.
  • Save the component file.

Component re-renders on every keystroke?

If typing in the comment form causes every comment in the tree to re-render, the form state is likely stored at a parent level and passed down. Move the form's local state (the text input value) into the form component itself, not the parent that renders the comment list.

help me debug why my comment components re-render when I type in the form

Test the full end-to-end flow

With both the backend and frontend reviewed and annotated, it is time to verify that everything works together. You will create a 3-level nested thread and confirm auth, images, and recursion all function at every depth.

  • Make sure your server and client are both running.
  • Log in with your test account.
  • Create a new top-level comment with an image attached.
  • Reply to that comment (level 2). Attach a different image to this reply.
  • Reply to the reply (level 3). This one can be text-only.

You should see all three levels rendered as a visually nested thread, with images displaying correctly at levels 1 and 2.

  • Edit the level-2 comment text. Confirm the edit saves and the thread stays intact.
  • Delete the level-3 comment. Confirm it disappears without breaking the parent thread.
  • Refresh the page. Confirm the entire nested structure loads correctly from the single GET request (the level-3 comment should be gone, and the edited text on level 2 should persist).

What confirms the backend serves a single query?

Open your browser's DevTools Network tab before refreshing. You should see only one XHR/Fetch request to your comments endpoint. The response payload should contain the full nested tree with children already populated inside each parent, not flat references that require follow-up requests.

Nested replies not displaying?

Check the Network tab response. If children (or replies) shows ObjectIds instead of full objects, your recursive populate is not reaching that depth. Confirm your populate function references itself on the nested children path.

help me fix my recursive populate so nested comments load fully

Your nested comment system is now fully reviewed, annotated, and optimized. You have a clear picture of how data flows from a single API call through Mongoose populate into a recursive React component tree. Next up, there is a Secret Mission waiting for you.

Secret mission

Add User Profile Avatars

Your comment system has authentication and image uploads, but every user looks the same. What if each commenter had a profile avatar displayed next to their name? In this secret mission, you'll wire up a new Cloudinary-powered avatar upload and connect it to every rendered comment.

Clean Up Your Resources

Clean Up Your Resources

This project runs locally and uses Cloudinary's free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local server and client project files (new auth, upload, and config files added to your existing MERN project).
  • Cloudinary account with uploaded images in the comments folder.
  • MongoDB Atlas data (User collection with registered accounts, image URLs stored on comments).
  • npm packages installed in your server directory (jsonwebtoken, bcryptjs, cloudinary, multer).

Keep everything running

No action needed. Choose this if you're still actively building on your comment system or plan to add more features soon.

  • Your Cloudinary free tier has 25 credits/month with no expiration and no credit card on file. There is nothing to pay.
  • Your local project files remain on disk. Your MongoDB Atlas cluster continues running on its existing plan.
  • The npm packages (jsonwebtoken, bcryptjs, cloudinary, multer) remain installed in your server/node_modules directory.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and accounts stay intact for when you return.

  • Stop your development server by pressing Ctrl+C in the terminal where it is running.
  • Stop your client dev server (if running separately) with Ctrl+C as well.
  • Your Cloudinary account and uploaded images remain available at no cost on the free tier.
  • Your MongoDB Atlas data persists on your existing cluster.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to clean up completely.

  • Stop your running servers by pressing Ctrl+C in each terminal.
  • Remove the npm packages you installed by running this in your server directory:
npm uninstall jsonwebtoken bcryptjs cloudinary multer
  • Delete the new server files you created during this project:
rm server/models/User.js server/middleware/auth.js server/middleware/upload.js server/config/cloudinary.js server/utils/cloudinaryUpload.js
  • Revert server/routes/auth.js to its state before this project (or delete it if it did not exist previously).
  • Delete the frontend auth files:
rm client/src/features/auth/authApi.js client/src/features/auth/authSlice.js
  • Remove the .env variables you added (JWT_SECRET, CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET) from your server/.env file.
  • Delete uploaded images from Cloudinary. Log in to your Cloudinary Console, open the Media Library, navigate to the comments folder, select all assets, and click the Delete icon in the toolbar.
  • Cancel your Cloudinary account if you no longer need it. Go to Console Settings > Billing > Plans. Click the Start the process here link at the top of the page and follow the cancellation flow.
  • Drop the User collection from your MongoDB Atlas database. In your MongoDB Atlas console, navigate to your cluster, open Browse Collections, find the users collection, and click the trash icon to drop it.

Nice Work!

Nice Work!

You just transformed a prototype nested comment system into a production-style application with real authentication and cloud-hosted media. Your MERN stack now handles secure user sessions, image uploads, and deeply nested data patterns that you can explain and extend.

You've learned how to:

  • Build a complete JWT authentication system with a User model, register/login endpoints, token middleware, and React auth pages wired through RTK Query.
  • Integrate Cloudinary image uploads via multer memory storage and upload_stream, with images attached to comments and stored in the cloud.
  • Review and optimize the recursive nested comment system, understanding both the Mongoose populate chain on the backend and the recursive React component pattern on the frontend.
  • Secret Mission: Added user profile avatars uploaded to Cloudinary and displayed alongside every comment.

Ready to quiz yourself?