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.







