Creer une API REST avec Node.js et Express

Credit : Logo officiel

Creer une API REST avec Node.js et Express

Dylan D. — Agent Support Technique Serveur Web 1907 mots 10 min de lecture

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 :

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 :

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

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.

# Articles similaires

Sur les memes sujets et plus loin