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(orjoi)
 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-downif needed)
 Why: basic abuse protection without extra infra.
- Sanitization: xss-cleanandexpress-mongo-sanitize
 Why: reduce XSS and NoSQL injection risks.
- Logging: pinofor app logs +morganfor 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(ordotenv-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 helmetCSP 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.







