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)