Implement Create, Read, Update, and Delete operations with Mongoose and Express routes.
CRUD stands for Create, Read, Update, Delete — the four fundamental operations you can perform on any piece of data in any application. If you can do these four things, you can build any data-driven feature. Every form submission, every profile edit, every search result, every "delete my account" button maps to one of these operations.
CRUD maps directly to HTTP methods, which means it maps directly to your Express routes:
| Operation | HTTP Method | Mongoose Method | Example |
|---|---|---|---|
| Create | POST | Model.create() or new Model().save() | Sign up, write a post |
| Read | GET | Model.find(), Model.findById() | View profile, list products |
| Update | PUT / PATCH | Model.findByIdAndUpdate() | Edit profile, change password |
| Delete | DELETE | Model.findByIdAndDelete() | Delete account, remove post |
This pattern is universal. Whether you are building a blog, an e-commerce platform, a social network, or a project management tool, the core data operations are always CRUD. The difference between applications is mostly what data they manage and what rules they enforce — not how they manage it at the fundamental level.
In Express, each CRUD operation becomes a route handler — a function that receives a request, performs the database operation, and sends back a response. The route's URL identifies the resource (/api/users), and the HTTP method identifies the operation (GET, POST, PUT, DELETE). This combination of URL + method creates a clean, predictable API structure that any frontend or mobile app can consume.
In this lesson, you will implement all four CRUD operations with proper error handling, status codes, and best practices.
const express = require('express');
const router = express.Router();
const User = require('../models/User');
// CREATE a new user
// POST /api/users
// Request body: { name: "Alice", email: "alice@example.com", age: 28 }
router.post('/api/users', async (req, res) => {
try {
// 1. Extract data from request body
const { name, email, age } = req.body;
// 2. Create the document in MongoDB
// Mongoose validates against the schema automatically
const user = await User.create({ name, email, age });
// 3. Return the created document with 201 status
// 201 = Created (resource was successfully created)
res.status(201).json({
success: true,
data: user
});
} catch (error) {
// Handle validation errors (missing required fields, etc.)
if (error.name === 'ValidationError') {
return res.status(400).json({
success: false,
error: error.message
});
}
// Handle duplicate key error (unique constraint violation)
if (error.code === 11000) {
return res.status(409).json({
success: false,
error: 'A user with that email already exists'
});
}
// Handle unexpected errors
res.status(500).json({
success: false,
error: 'Server error'
});
}
});
// Alternative: using new + save()
// const user = new User({ name, email, age });
// await user.save(); // Also triggers validation// READ all users (with optional query filters)
// GET /api/users
// GET /api/users?role=admin&sort=name
router.get('/api/users', async (req, res) => {
try {
// Build query from URL query parameters
const filter = {};
if (req.query.role) filter.role = req.query.role;
if (req.query.name) filter.name = new RegExp(req.query.name, 'i');
// Find all users matching the filter
const users = await User.find(filter)
.sort({ createdAt: -1 }) // Newest first
.select('name email role') // Only return these fields
.limit(50); // Cap results at 50
res.status(200).json({
success: true,
count: users.length,
data: users
});
} catch (error) {
res.status(500).json({ success: false, error: 'Server error' });
}
});
// READ a single user by ID
// GET /api/users/507f1f77bcf86cd799439011
router.get('/api/users/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id);
// Always handle the "not found" case!
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
res.status(200).json({
success: true,
data: user
});
} catch (error) {
// Invalid ObjectId format triggers a CastError
if (error.name === 'CastError') {
return res.status(400).json({
success: false,
error: 'Invalid user ID format'
});
}
res.status(500).json({ success: false, error: 'Server error' });
}
});// UPDATE a user by ID
// PUT /api/users/507f1f77bcf86cd799439011
// Request body: { name: "Alice Updated", age: 29 }
router.put('/api/users/:id', async (req, res) => {
try {
const user = await User.findByIdAndUpdate(
req.params.id, // The ID to find
req.body, // The update data
{
new: true, // Return the UPDATED document (not the old one)
runValidators: true // Run schema validators on the update
}
);
// Handle not found
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
// Return updated document with 200 status
res.status(200).json({
success: true,
data: user
});
} catch (error) {
if (error.name === 'ValidationError') {
return res.status(400).json({
success: false,
error: error.message
});
}
res.status(500).json({ success: false, error: 'Server error' });
}
});
// Note on PUT vs PATCH:
// PUT = replace the entire document (send ALL fields)
// PATCH = update specific fields only (send ONLY changed fields)
// findByIdAndUpdate works for both — the difference is semantic
// Many APIs use PATCH for partial updates in practice// DELETE a user by ID
// DELETE /api/users/507f1f77bcf86cd799439011
router.delete('/api/users/:id', async (req, res) => {
try {
const user = await User.findByIdAndDelete(req.params.id);
// Handle not found
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
// 204 No Content — successful deletion, nothing to return
// Some APIs return 200 with the deleted document instead
res.status(204).send();
} catch (error) {
if (error.name === 'CastError') {
return res.status(400).json({
success: false,
error: 'Invalid user ID format'
});
}
res.status(500).json({ success: false, error: 'Server error' });
}
});
// WARNING: findByIdAndDelete permanently removes the document.
// For important data, consider "soft deletes" instead:
// Instead of deleting, set a field: { deleted: true, deletedAt: Date.now() }
// Then filter out deleted documents in your find queries:
// User.find({ deleted: { $ne: true } })
module.exports = router;Following these best practices will save you from common bugs and make your APIs more robust and professional.
Always validate input. Never trust data from the client. Use Mongoose schema validation as your last line of defense, but ideally also validate the request body before passing it to the database. Libraries like Joi or Zod can validate incoming data at the route level. Check that required fields exist, types are correct, and values are within acceptable ranges.
Always handle the "not found" case. When finding by ID, the result might be null — the document might not exist, or the user might have passed a wrong ID. Always check for null and return a clear 404 response. Forgetting this leads to Cannot read property 'name' of null crashes.
Return appropriate HTTP status codes. Status codes communicate what happened: 200 OK for successful reads and updates, 201 Created for successful creation, 204 No Content for successful deletion, 400 Bad Request for invalid input, 404 Not Found for missing resources, 409 Conflict for duplicate key violations, 500 Internal Server Error for unexpected failures. Correct status codes help frontend developers handle responses properly.
Use try/catch for async operations. Every database operation can fail — network errors, timeout errors, validation errors, constraint violations. Wrap all async/await calls in try/catch blocks. Without error handling, a single failed query crashes your entire server.
Never expose internal errors to clients. In production, send generic error messages like "Server error" instead of raw database errors. Internal error details can reveal your database structure and create security vulnerabilities. Log the full error on the server for debugging, but send a sanitized version to the client.
Return a consistent response format. Pick a response structure and stick with it across all endpoints. A common pattern is { success: boolean, data: any, error?: string, count?: number }. Consistency makes your API predictable and easier for frontend developers to consume.
Use pagination for list endpoints. Never return all documents at once. Use .limit() and .skip() (or cursor-based pagination) to return data in pages. An endpoint that returns 100,000 users at once will crash browsers and waste bandwidth.
Which Mongoose method updates a document and returns the updated version?