Setting up a new backend today: Node.js + Express, MongoDB, JWT, CORS, logging, and PM2 (what I use and why)

Reading Time: 5 minutes

I spin up a lot of small APIs. Here is the stack I keep coming back to for a solid, modern, and secure baseline. Libraries first, plus short “why,” then a skinny walkthrough you can copy.

Core stack

  • Runtime: Node 20 LTS
  • Framework: express
    Why: tiny surface area, huge ecosystem, easy to reason about.
  • Database: MongoDB with mongoose
    Why: schemas, validation, hooks, and a good dev experience.
  • Auth: jsonwebtoken + bcrypt
    Why: battle-tested JWTs, salted password hashing.
  • Validation: zod (or joi)
    Why: validate inputs at the edge, generate types if you want.
  • CORS: cors
    Why: explicit origin control and credentials where needed.
  • Security headers: helmet
    Why: sane defaults for common web risks.
  • Rate limiting: express-rate-limit (+ express-slow-down if needed)
    Why: basic abuse protection without extra infra.
  • Sanitization: xss-clean and express-mongo-sanitize
    Why: reduce XSS and NoSQL injection risks.
  • Logging: pino for app logs + morgan for HTTP combined format in dev
    Why: fast JSON logs for production, readable in dev.
  • Request IDs: cls-rtracer
    Why: trace a request across logs quickly.
  • Compression: compression
    Why: smaller payloads by default.
  • Docs: swagger-ui-express + OpenAPI YAML
    Why: zero-friction docs and try-it-out for the team.
  • Config: dotenv (or dotenv-flow)
    Why: clean env handling across local, staging, prod.
  • Process manager: pm2
    Why: zero-downtime reloads, clustering, startup scripts, log rotation.

Folder layout

/src
  /config
  /routes
  /controllers
  /middlewares
  /models
  /utils
  server.js
  app.js
/openapi.yaml
ecosystem.config.js
.env.example

Install

npm init -y
npm i express mongoose jsonwebtoken bcrypt zod cors helmet express-rate-limit express-slow-down xss-clean express-mongo-sanitize compression pino pino-pretty morgan cls-rtracer swagger-ui-express dotenv
npm i -D nodemon

Minimal app.js

const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const slowDown = require('express-slow-down');
const xss = require('xss-clean');
const mongoSanitize = require('express-mongo-sanitize');
const compression = require('compression');
const morgan = require('morgan');
const rTracer = require('cls-rtracer');
const pino = require('pino')();
const swaggerUi = require('swagger-ui-express');
const fs = require('fs');

const app = express();

// Core middleware
app.use(helmet());
app.use(cors({ origin: ['https://yourfrontend.com'], credentials: true }));
app.use(express.json({ limit: '1mb' }));
app.use(compression());
app.use(xss());
app.use(mongoSanitize());
app.use(rTracer.expressMiddleware());

// Logging
if (process.env.NODE_ENV !== 'production') {
  app.use(morgan('dev'));
}
app.use((req, res, next) => {
  req.log = pino.child({ rid: rTracer.id() });
  next();
});

// Throttle bad actors
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 300 });
const speed = slowDown({ windowMs: 15 * 60 * 1000, delayAfter: 100, delayMs: 250 });
app.use(limiter);
app.use(speed);

// Health
app.get('/health', (req, res) => res.json({ ok: true }));

// Example protected route
const { auth } = require('./middlewares/auth');
app.get('/api/me', auth, (req, res) => res.json({ userId: req.user.sub }));

// Swagger
const openapi = fs.readFileSync('./openapi.yaml', 'utf8');
app.use('/docs', swaggerUi.serve, swaggerUi.setup(undefined, { swaggerOptions: { url: '/openapi.yaml' } }));
app.get('/openapi.yaml', (_, res) => res.type('text/yaml').send(openapi));

module.exports = app;

JWT auth middleware middlewares/auth.js

const jwt = require('jsonwebtoken');

exports.auth = (req, res, next) => {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: 'missing token' });

  try {
    const payload = jwt.verify(token, process.env.JWT_PUBLIC, { algorithms: ['RS256'] });
    req.user = payload;
    next();
  } catch {
    return res.status(401).json({ error: 'invalid token' });
  }
};

Users model models/User.js

const { Schema, model } = require('mongoose');
const bcrypt = require('bcrypt');

const UserSchema = new Schema({
  email: { type: String, unique: true, required: true, index: true },
  passwordHash: { type: String, required: true },
}, { timestamps: true });

UserSchema.methods.setPassword = async function (pw) {
  this.passwordHash = await bcrypt.hash(pw, 12);
};
UserSchema.methods.checkPassword = function (pw) {
  return bcrypt.compare(pw, this.passwordHash);
};

module.exports = model('User', UserSchema);

Validation example with Zod

const { z } = require('zod');

const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(10),
});

exports.validate = schema => (req, res, next) => {
  const result = schema.safeParse(req.body);
  if (!result.success) return res.status(400).json({ error: result.error.issues });
  req.body = result.data;
  next();
};

exports.registerSchema = registerSchema;

Auth routes routes/auth.js

const router = require('express').Router();
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const { validate, registerSchema } = require('../middlewares/validate');

router.post('/register', validate(registerSchema), async (req, res) => {
  const user = new User({ email: req.body.email });
  await user.setPassword(req.body.password);
  await user.save();
  res.status(201).json({ ok: true });
});

router.post('/login', async (req, res) => {
  const { email, password } = req.body || {};
  const user = await User.findOne({ email });
  if (!user || !(await user.checkPassword(password))) return res.status(401).json({ error: 'bad credentials' });

  const token = jwt.sign({ sub: user.id }, process.env.JWT_PRIVATE, { algorithm: 'RS256', expiresIn: '1h' });
  res.json({ token });
});

module.exports = router;

Server bootstrap server.js

require('dotenv').config();
const mongoose = require('mongoose');
const app = require('./app');

const PORT = process.env.PORT || 3000;

async function start() {
  await mongoose.connect(process.env.MONGO_URI);
  const server = app.listen(PORT, () => console.log(`API on :${PORT}`));

  // graceful shutdown
  const stop = () => {
    console.log('shutting down');
    server.close(() => mongoose.connection.close(false).then(() => process.exit(0)));
  };
  process.on('SIGINT', stop);
  process.on('SIGTERM', stop);
}

start();

PM2 ecosystem ecosystem.config.js

module.exports = {
  apps: [{
    name: 'my-api',
    script: 'server.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: { NODE_ENV: 'production' },
    watch: false,
    max_restarts: 10,
    time: true,
    error_file: './logs/err.log',
    out_file: './logs/out.log',
    merge_logs: true,
  }]
};

Env example .env.example

NODE_ENV=development
PORT=3000
MONGO_URI=mongodb://localhost:27017/myapi
# Use RS256 for JWTs
JWT_PRIVATE=-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----
JWT_PUBLIC=-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----

Production notes

  • Put PM2 behind Nginx. Terminate TLS at Nginx, forward to PM2 on localhost.
  • Use log rotation. PM2 has a module, or use your OS service.
  • Enable MongoDB auth, user roles, and proper backups.
  • Keep JWT secrets in a proper secrets store.
  • Turn off Swagger in production or guard it behind auth.
  • Add helmet CSP if you serve any views or static docs.

If folks want it, I can zip this into a starter repo with OpenAPI, basic tests, and Docker. Leave a comment below!


Discover more from AJB Blog

Subscribe to get the latest posts sent to your email.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.