Credit : Logo officiel
Creer une API REST avec Node.js et Express
De zero a la prod en une apres-midi
La semaine derniere, un client m'a appele en panique : son agence avait livre une API PHP qui faisait 3 secondes par requete sur du JSON pur. Je lui ai propose de la refaire en Node.js + Express. Resultat : 80 ms de latence moyenne, deploye en moins de 4 heures sur un VPS IONOS 2 vCPU. C'est pour ce genre de cas qu'Express reste imbattable en 2026.
Dans ce guide je couvre l'integralite du parcours : initialisation, structure du projet, middleware, modele Mongoose, validation Joi, auth JWT, routes CRUD, gestion d'erreurs et deploiement PM2. Le code tourne sur Node.js 22 LTS et Express 4.21 (Express 5 est sortie mais pour une mise en prod aujourd'hui je reste sur la 4.x, plus stable cote ecosysteme).
Initialiser le projet proprement
Structure de dossiers que j'utilise
Apres plusieurs centaines de projets, j'ai converge vers cette arborescence :
mon-api/
src/
config/ # Connexion DB, env
middleware/ # auth, logger, errors
models/ # Schemas Mongoose
routes/ # Endpoints
validators/ # Schemas Joi
services/ # Logique metier
index.js # Point d'entree
.env
.env.example
package.json
Ca parait verbeux pour un petit projet mais des qu'on depasse 4-5 routes, c'est indispensable. J'ai vu trop de fichiers app.js de 800 lignes que personne n'osait toucher.
Installation des dependances
mkdir mon-api && cd mon-api
npm init -y
npm install express@^4.21 mongoose@^8 jsonwebtoken@^9 bcryptjs joi dotenv cors helmet express-rate-limit morgan
npm install -D nodemon
Le script de dev dans package.json :
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js",
"test": "node --test"
}
Notez node --test : depuis Node 20, il y a un test runner integre. Plus besoin de Mocha/Jest pour des tests basiques.
Le point d'entree avec tous les middleware essentiels
Creez src/index.js :
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
require('dotenv').config();
const app = express();
// Middleware globaux
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Rate limiting global
app.use('/api', rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false
}));
// Connexion MongoDB
mongoose.connect(process.env.MONGO_URI)
.then(() => console.log('MongoDB connecte'))
.catch(err => {
console.error('Erreur MongoDB:', err);
process.exit(1);
});
// Healthcheck
app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() }));
// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/articles', require('./routes/articles'));
// 404
app.use((req, res) => res.status(404).json({ error: 'Route non trouvee' }));
// Gestion d'erreurs globale
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: err.message || 'Erreur interne du serveur'
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`API demarree sur le port ${PORT}`));
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM recu, fermeture propre');
await mongoose.connection.close();
process.exit(0);
});
Points qu'on oublie souvent :
- Helmet : ajoute des headers de securite en une ligne. X-Frame-Options, X-Content-Type-Options, CSP basique. Faites-le.
- express-rate-limit : protege contre le brute force et le scraping abusif. Critique en prod.
- Healthcheck : indispensable pour le load balancer et la supervision Netdata.
- Graceful shutdown : sans ca, PM2 tue brutalement le process et vous perdez les requetes en cours.
Le fichier .env
Ne versionnez JAMAIS ce fichier. Mettez .env dans .gitignore et committez .env.example :
NODE_ENV=production
PORT=3000
MONGO_URI=mongodb://127.0.0.1:27017/mon-api
JWT_SECRET=changez-moi-ed25519-base64-au-moins-64-caracteres
JWT_EXPIRES_IN=24h
CORS_ORIGIN=https://monsite.fr
Pour generer un secret JWT robuste :
openssl rand -base64 64
Le modele Mongoose avec hash du mot de passe
src/models/User.js :
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
password: { type: String, required: true, minlength: 8, select: false },
name: { type: String, required: true, trim: true },
role: { type: String, enum: ['user', 'admin'], default: 'user' }
}, { timestamps: true });
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
userSchema.methods.comparePassword = function(candidate) {
return bcrypt.compare(candidate, this.password);
};
module.exports = mongoose.model('User', userSchema);
Le select: false sur le password : par defaut le champ ne sera plus retourne par les find(). Pour le recuperer dans la route login, il faudra User.findOne({ email }).select('+password'). C'est une protection contre les fuites accidentelles.
src/models/Article.js :
const mongoose = require('mongoose');
const articleSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true, maxlength: 200 },
slug: { type: String, required: true, unique: true, lowercase: true },
content: { type: String, required: true },
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true },
tags: [{ type: String, lowercase: true, trim: true }],
status: { type: String, enum: ['draft', 'published'], default: 'draft', index: true }
}, { timestamps: true });
articleSchema.index({ title: 'text', content: 'text' });
articleSchema.index({ status: 1, createdAt: -1 });
module.exports = mongoose.model('Article', articleSchema);
L'index compose { status: 1, createdAt: -1 } : MongoDB l'utilisera pour les requetes de listing avec tri. Sans cet index, sur un gros volume vous explosez les CPU pour rien.
La validation avec Joi
src/validators/article.js :
const Joi = require('joi');
const articleSchema = Joi.object({
title: Joi.string().min(3).max(200).required(),
slug: Joi.string().pattern(/^[a-z0-9-]+$/).max(100),
content: Joi.string().min(10).required(),
tags: Joi.array().items(Joi.string().max(30)).max(10),
status: Joi.string().valid('draft', 'published')
});
const validate = (schema) => (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false, stripUnknown: true });
if (error) {
return res.status(400).json({
error: 'Validation failed',
details: error.details.map(d => ({ field: d.path.join('.'), message: d.message }))
});
}
req.body = value;
next();
};
module.exports = { validate, articleSchema };
Le stripUnknown: true est crucial : il retire silencieusement les champs non declares. Sans ca, un client peut injecter role: 'admin' dans un POST et passer admin. Vu deux fois en code review.
Authentification JWT robuste
src/middleware/auth.js :
const jwt = require('jsonwebtoken');
const auth = (req, res, next) => {
const header = req.header('Authorization');
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Token manquant' });
}
try {
const token = header.replace('Bearer ', '');
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
const msg = err.name === 'TokenExpiredError' ? 'Token expire' : 'Token invalide';
res.status(401).json({ error: msg });
}
};
const requireRole = (role) => (req, res, next) => {
if (req.user?.role !== role) {
return res.status(403).json({ error: 'Acces interdit' });
}
next();
};
module.exports = { auth, requireRole };
Route de login src/routes/auth.js :
const router = require('express').Router();
const jwt = require('jsonwebtoken');
const User = require('../models/User');
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email }).select('+password');
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ error: 'Identifiants invalides' });
}
const token = jwt.sign(
{ id: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
);
res.json({ token, user: { id: user._id, email: user.email, name: user.name, role: user.role } });
});
module.exports = router;
Un truc que j'ai appris a la dure un vendredi soir : mettez TOUJOURS un try/catch autour de jwt.verify(). Si le token est corrompu et que l'exception remonte, votre handler global la chope mais le code synchrone autour peut etre dans un etat inconsistant.
Les routes CRUD avec pagination optimisee
src/routes/articles.js :
const router = require('express').Router();
const Article = require('../models/Article');
const { auth } = require('../middleware/auth');
const { validate, articleSchema } = require('../validators/article');
// GET /api/articles - Liste avec pagination
router.get('/', async (req, res, next) => {
try {
const page = Math.max(parseInt(req.query.page) || 1, 1);
const limit = Math.min(parseInt(req.query.limit) || 10, 50);
const skip = (page - 1) * limit;
const filter = { status: 'published' };
const [articles, total] = await Promise.all([
Article.find(filter)
.populate('author', 'name')
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.lean(),
Article.countDocuments(filter)
]);
res.json({ articles, page, totalPages: Math.ceil(total / limit), total });
} catch (err) { next(err); }
});
// GET /api/articles/:id
router.get('/:id', async (req, res, next) => {
try {
const article = await Article.findById(req.params.id).populate('author', 'name').lean();
if (!article) return res.status(404).json({ error: 'Article non trouve' });
res.json(article);
} catch (err) { next(err); }
});
// POST /api/articles - Creer (authentifie)
router.post('/', auth, validate(articleSchema), async (req, res, next) => {
try {
const article = await Article.create({ ...req.body, author: req.user.id });
res.status(201).json(article);
} catch (err) { next(err); }
});
// PUT /api/articles/:id
router.put('/:id', auth, validate(articleSchema), async (req, res, next) => {
try {
const article = await Article.findOneAndUpdate(
{ _id: req.params.id, author: req.user.id },
req.body,
{ new: true, runValidators: true }
);
if (!article) return res.status(404).json({ error: 'Article non trouve' });
res.json(article);
} catch (err) { next(err); }
});
// DELETE /api/articles/:id
router.delete('/:id', auth, async (req, res, next) => {
try {
const article = await Article.findOneAndDelete({ _id: req.params.id, author: req.user.id });
if (!article) return res.status(404).json({ error: 'Article non trouve' });
res.json({ message: 'Article supprime' });
} catch (err) { next(err); }
});
module.exports = router;
Deux astuces critiques :
Promise.allpour le GET liste : count et find en parallele divise le temps de reponse par 2..lean()sur les requetes en lecture : retourne des objets JS plats au lieu de documents Mongoose. Sur 1000 documents, le gain memoire et CPU est enorme.
Erreurs courantes et leur fix
"CORS error" en navigateur mais pas avec curl
CURL ignore CORS. Il faut autoriser explicitement votre domaine front :
app.use(cors({
origin: ['https://monsite.fr', 'http://localhost:5173'],
credentials: true
}));
Memory leak progressif
Souvent du a des setInterval non nettoyes ou a des connexions Mongoose non fermees. Surveillez avec :
pm2 monit
# ou
node --inspect src/index.js
MongooseServerSelectionError aleatoire
Mongo 7+ exige souvent un keepalive. Ajoutez :
mongoose.connect(uri, { serverSelectionTimeoutMS: 5000, maxPoolSize: 10 });
Erreur 413 "Payload Too Large"
Vous envoyez du JSON > 100 KB. Augmentez la limite :
app.use(express.json({ limit: '5mb' }));
Token JWT "invalid signature" en prod
Quasi toujours : le JWT_SECRET n'est pas le meme entre les instances PM2. Mettez-le dans .env et chargez avant le cluster mode.
Deployer avec PM2 et Nginx en reverse proxy
npm install -g pm2
pm2 start src/index.js --name mon-api -i max
pm2 save
pm2 startup systemd
Le -i max lance autant d'instances que de cores CPU (mode cluster). Pour 4 vCPU vous obtenez 4x le throughput.
Reverse proxy Nginx :
server {
listen 443 ssl http2;
server_name api.monsite.fr;
ssl_certificate /etc/letsencrypt/live/api.monsite.fr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.monsite.fr/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
N'oubliez pas dans Express :
app.set('trust proxy', 1);
Sans ca, req.ip retournera toujours 127.0.0.1 et votre rate limiting tapera sur votre Nginx au lieu des vraies IP clientes.
Pour aller plus loin
- Docker pour debutants : conteneuriser une app Node.js
- Reverse proxy Nginx avec SSL Let's Encrypt
- Deployer une application via GitHub Actions
- Bases de MariaDB/MySQL pour administrateurs
- Configurer Redis comme cache pour vos applications
L'API qui survit a la prod
De zero a la prod en une apres-midi, c'est faisable. Mais pour qu'elle tienne 6 mois sans intervention, il faut soigner les middleware de securite, gerer le graceful shutdown, monitorer avec PM2 et logger correctement avec Morgan. Le squelette ci-dessus, je l'utilise sur une dizaine d'APIs en prod, du petit microservice interne a une plateforme qui encaisse 200 req/s. Investissez le temps de bien faire la fondation, vous gagnerez dix fois ca en debug evite.