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-down
if needed)
Why: basic abuse protection without extra infra. - Sanitization:
xss-clean
andexpress-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
(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
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.