Débutant30 min de lecture

CRUD Operations

Implement Create, Read, Update, and Delete operations with Mongoose and Express routes.

What is CRUD?

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:

OperationHTTP MethodMongoose MethodExample
CreatePOSTModel.create() or new Model().save()Sign up, write a post
ReadGETModel.find(), Model.findById()View profile, list products
UpdatePUT / PATCHModel.findByIdAndUpdate()Edit profile, change password
DeleteDELETEModel.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.

Create — POST /api/users

javascript
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 — GET /api/users and GET /api/users/:id

javascript
// 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 — PUT /api/users/:id

javascript
// 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 — DELETE /api/users/:id

javascript
// 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;

CRUD Best Practices

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?

Prêt à pratiquer ?

Crée ton compte gratuit pour accéder à l'éditeur de code interactif, lancer les défis et suivre ta progression.