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 package.json scripts:
{ "scripts": { "dev": "nodemon server.js", "start": "node server.js" } } .env (create it in the project root):
PORT=3000 MONGO_URI=mongodb://127.0.0.1:27017/crud 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); Run it:
npm run dev # ➜ http://localhost:3000/healthz 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"}' List (paginated):
curl "http://localhost:3000/api/v1/clients?page=1&limit=10" Get one:
curl http://localhost:3000/api/v1/clients/<id> 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"}' Delete:
curl -X DELETE http://localhost:3000/api/v1/clients/<id> -i # 204 No Content 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" } 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)