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)