Why the Register Controller is Critical
The register controller is often the first real controller you write in a MERN project. It combines input validation, database operations, file uploads, and error handling all in one place. Writing it correctly from the start establishes the patterns you'll follow throughout the entire backend.
---
Complete Register Flow — Step by Step
Step 1: Extract User Details from req.body
const { username, email, fullName, password } = req.body;
Step 2: Validation — Check Not Empty, Trim, Validate Email
// Check all required fields are present and not just whitespace
if ([username, email, fullName, password].some((field) => !field?.trim())) {
throw new ApiError(400, 'All fields are required');
}
// Validate email format with regex
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new ApiError(400, 'Invalid email format');
}
// Validate username: only lowercase letters, numbers, underscores
const usernameRegex = /^[a-z0-9_]{3,30}$/;
if (!usernameRegex.test(username.toLowerCase())) {
throw new ApiError(400, 'Username must be 3-30 chars: lowercase letters, numbers, underscores only');
}
// Validate password strength
if (password.length < 8) {
throw new ApiError(400, 'Password must be at least 8 characters');
}
Step 3: Check if User Already Exists
const existingUser = await User.findOne({
$or: [{ username: username.toLowerCase() }, { email: email.toLowerCase() }],
});
if (existingUser) {
if (existingUser.username === username.toLowerCase()) {
throw new ApiError(409, 'Username is already taken');
}
throw new ApiError(409, 'An account with this email already exists');
}
Using $or checks both username and email in a single database query instead of two separate queries.
Step 4: Check for Avatar File
// req.files is populated by Multer middleware
const avatarLocalPath = req.files?.avatar?.[0]?.path;
if (!avatarLocalPath) {
throw new ApiError(400, 'Avatar image is required');
}
// Cover image is optional
const coverImageLocalPath = req.files?.coverImage?.[0]?.path;
Step 5: Upload Avatar to Cloudinary
const avatar = await uploadOnCloudinary(avatarLocalPath);
if (!avatar?.secure_url) {
throw new ApiError(500, 'Failed to upload avatar. Please try again.');
}
let coverImageUrl = '';
if (coverImageLocalPath) {
const coverImage = await uploadOnCloudinary(coverImageLocalPath);
coverImageUrl = coverImage?.secure_url || '';
}
Step 6: Create User in Database
const user = await User.create({
fullName,
email: email.toLowerCase().trim(),
username: username.toLowerCase().trim(),
password, // Will be hashed by pre-save hook
avatar: avatar.secure_url,
coverImage: coverImageUrl,
});
Step 7: Fetch Created User — Exclude Sensitive Fields
const createdUser = await User.findById(user._id).select('-password -refreshToken');
if (!createdUser) {
throw new ApiError(500, 'Something went wrong while creating the user');
}
We re-fetch instead of using the user object from User.create() because the password field is select: false — re-fetching gives us the clean document without sensitive fields.
Step 8: Return Success Response
return res
.status(201)
.json(new ApiResponse(201, createdUser, 'User registered successfully'));
---
Input Sanitisation
Always sanitise user input before using it:
- trim(): Remove leading and trailing whitespace
- toLowerCase(): Normalise email and username for consistency
- Do not trust the client: Re-validate on the server even if you validate on the frontend
Never use user-provided input directly in MongoDB queries without sanitisation. Use Mongoose's built-in validators and avoid $where queries with user input to prevent NoSQL injection.
---
Error Handling at Each Step
| Step | What Can Go Wrong | Error to Throw |
|---|---|---|
| Input extraction | Missing fields, wrong types | 400 Bad Request |
| Email validation | Invalid format | 400 Bad Request |
| Duplicate check | Username or email taken | 409 Conflict |
| File check | Avatar not uploaded | 400 Bad Request |
| Cloudinary upload | Network error, API limit | 500 Internal Server Error |
| User.create() | DB error, validation failure | 500 / 422 |
| Re-fetch | Extremely rare DB inconsistency | 500 Internal Server Error |
---
Common Register Controller Mistakes
- Not checking
req.filesfor undefined — always use optional chaining:req.files?.avatar?.[0]?.path - Not deleting temp files on error — if Cloudinary upload fails, the temp file stays. The
uploadOnCloudinaryutility handles this. - Not using
$orfor duplicate check — two separate queries are less efficient and can have race conditions - Storing unhashed passwords — the pre-save hook handles hashing, but make sure it is correctly defined
- Returning the user object from
User.create()directly — it includes the hashed password. Always re-fetch with.select('-password -refreshToken')