DEV Community

A0mineTV
A0mineTV

Posted on

Build a Secure CRUD API with Node.js, Express & MongoDB (Mongoose)

TL;DR

We’ll build a CRUD REST API for a Client resource with Node.js + Express + Mongoose. You’ll get:

  • Input validation & unique email checks
  • Pagination + lean reads for speed
  • Centralized error handling with consistent JSON
  • Basic security (Helmet, CORS, rate limiting)
  • Ready-to-deploy structure with .env, health check, and graceful shutdown

Repo structure is single-file for clarity, but production-ready concepts.


1) Prerequisites

  • Node 18+ (or 20+)
  • MongoDB running locally or in the cloud (Atlas)
  • Basic terminal & REST knowledge
mkdir secure-crud-api && cd secure-crud-api
npm init -y
npm i express mongoose dotenv helmet cors morgan express-rate-limit
npm i -D nodemon
Enter fullscreen mode Exit fullscreen mode

package.json scripts:

{
  "scripts": {
    "dev": "nodemon server.js",
    "start": "node server.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

.env (create it in the project root):

PORT=3000
MONGO_URI=mongodb://127.0.0.1:27017/crud
Enter fullscreen mode Exit fullscreen mode

2) The API (copy-paste server.js)

This is a minimal but solid baseline you can ship.

// server.js
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');

const app = express();
const PORT = process.env.PORT || 3000;
const MONGO_URI = process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/crud';

// --- Core middleware
app.use(helmet());
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '100kb' }));
app.use(morgan('dev'));
app.use(rateLimit({ windowMs: 60_000, max: 100 }));

// --- Mongo connection
mongoose
  .connect(MONGO_URI)
  .then(() => console.log('✅ MongoDB connected'))
  .catch((err) => {
    console.error('❌ MongoDB connection error:', err);
    process.exit(1);
  });

// --- Mongoose model
const clientSchema = new mongoose.Schema(
  {
    nom: { type: String, required: true, trim: true, minlength: 1, maxlength: 120 },
    email: {
      type: String,
      required: true,
      trim: true,
      lowercase: true,
      unique: true,
      match: [/^\S+@\S+\.\S+$/, 'Invalid email'],
    },
    telephone: { type: String, trim: true },
  },
  { timestamps: true, versionKey: false }
);

// Map _id -> id in JSON
clientSchema.set('toJSON', {
  transform: (_doc, ret) => {
    ret.id = ret._id;
    delete ret._id;
    return ret;
  },
});

const Client = mongoose.model('Client', clientSchema);

// --- Async wrapper
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);

// --- Routes (v1)
app.get('/healthz', (_req, res) => res.json({ status: 'ok' }));

// Create
app.post('/api/v1/clients', asyncHandler(async (req, res) => {
  const { nom, email, telephone } = req.body || {};
  if (!nom || !email) return res.status(400).json({ error: 'nom and email are required' });
  const client = await Client.create({ nom, email, telephone });
  res.status(201).json(client);
}));

// List with pagination
app.get('/api/v1/clients', asyncHandler(async (req, res) => {
  const page = Math.max(parseInt(req.query.page) || 1, 1);
  const limit = Math.min(parseInt(req.query.limit) || 20, 100);
  const skip = (page - 1) * limit;

  const [items, total] = await Promise.all([
    Client.find().lean().skip(skip).limit(limit).sort({ createdAt: -1 }),
    Client.countDocuments(),
  ]);

  res.json({ items, page, limit, total, pages: Math.ceil(total / limit) });
}));

// Read one
app.get('/api/v1/clients/:id', asyncHandler(async (req, res) => {
  const client = await Client.findById(req.params.id);
  if (!client) return res.status(404).json({ error: 'Client not found' });
  res.json(client);
}));

// Partial update (PATCH) with validation
app.patch('/api/v1/clients/:id', asyncHandler(async (req, res) => {
  const allowed = ['nom', 'email', 'telephone'];
  const update = Object.fromEntries(
    Object.entries(req.body || {}).filter(([k]) => allowed.includes(k))
  );

  const client = await Client.findByIdAndUpdate(req.params.id, update, {
    new: true,
    runValidators: true,
    context: 'query',
  });

  if (!client) return res.status(404).json({ error: 'Client not found' });
  res.json(client);
}));

// Delete
app.delete('/api/v1/clients/:id', asyncHandler(async (req, res) => {
  const client = await Client.findByIdAndDelete(req.params.id);
  if (!client) return res.status(404).json({ error: 'Client not found' });
  res.status(204).send();
}));

// --- Centralized error handler
app.use((err, _req, res, _next) => {
  if (err.name === 'CastError') return res.status(400).json({ error: 'Invalid ID' });
  if (err.code === 11000) return res.status(409).json({ error: 'Email already in use' });
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

// --- Graceful shutdown
const server = app.listen(PORT, () => console.log(`🚀 http://localhost:${PORT}`));
function shutdown() {
  console.log('Shutting down…');
  server.close(() => mongoose.connection.close(false, () => process.exit(0)));
  setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
Enter fullscreen mode Exit fullscreen mode

Run it:

npm run dev
# ➜ http://localhost:3000/healthz
Enter fullscreen mode Exit fullscreen mode

3) Test the Endpoints (cURL)

Create:

curl -X POST http://localhost:3000/api/v1/clients \
  -H "Content-Type: application/json" \
  -d '{"nom":"Ada Lovelace","email":"ada@example.com","telephone":"+33 6 12 34 56 78"}'
Enter fullscreen mode Exit fullscreen mode

List (paginated):

curl "http://localhost:3000/api/v1/clients?page=1&limit=10"
Enter fullscreen mode Exit fullscreen mode

Get one:

curl http://localhost:3000/api/v1/clients/<id>
Enter fullscreen mode Exit fullscreen mode

Update (PATCH):

curl -X PATCH http://localhost:3000/api/v1/clients/<id> \
  -H "Content-Type: application/json" \
  -d '{"telephone":"+33 7 98 76 54 32"}'
Enter fullscreen mode Exit fullscreen mode

Delete:

curl -X DELETE http://localhost:3000/api/v1/clients/<id> -i
# 204 No Content
Enter fullscreen mode Exit fullscreen mode

4) Why These Choices?

  • Validation & uniqueness in the schema keep data clean and prevent duplicates (unique: true, regex on email).
  • PATCH + runValidators ensures updates still respect rules.
  • Pagination + .lean() keeps listing fast and memory-friendly.
  • Centralized error handler → consistent JSON responses your frontend can rely on.
  • Helmet, CORS, rate limiting → quick security wins.
  • Health check & graceful shutdown → friendlier to Docker/K8s and CI.

5) API Reference

Method Path Description
POST /api/v1/clients Create a client
GET /api/v1/clients List clients (paginated)
GET /api/v1/clients/:id Get one client
PATCH /api/v1/clients/:id Update allowed fields
DELETE /api/v1/clients/:id Delete a client
GET /healthz Health check

Pagination params: page (default 1), limit (default 20, max 100)

Error shape:

{ "error": "Human-readable message" }
Enter fullscreen mode Exit fullscreen mode

6) Production Tips

  • Use MongoDB Atlas and store MONGO_URI in secrets.
  • Add request logging to files in production (morgan “combined”).
  • Enforce CORS allowlist (specific domains) rather than origin: true.
  • Consider DTO/validation libraries (Zod, express-validator) for larger apps.
  • Split code into routes / controllers / services / models once your API grows.

7) Nice Extras (Optional)

  • Dockerize (node:20-alpine) and run Mongo as a service
  • E2E tests with supertest + vitest/jest
  • OpenAPI/Swagger docs for your frontend & QA teams

Final Words

This template gives you a clean, safe base you can extend with auth, filtering, and role-based access. Drop a comment if you want the ESM + modular folders version or a Swagger/OpenAPI add-on!

Top comments (0)