After running an API service in production for 6 months handling thousands of requests per day, I realized the hardest part isn’t writing the code — it’s designing the API correctly from the start. A poorly named endpoint, an inconsistent response structure, or forgetting to version from v1 — all of these turn into painful technical debt when you need to scale.
This post captures what I’ve applied after both building and refactoring that API — from how to structure routes, validate input, and handle JWT authentication, to managing API versions in a sustainable way.
The Real Problem with “Just Make It Work” REST APIs
A lot of projects start with Express the same way: create a server.js file, add a few routes, and call it done. That’s fine for prototyping — but as the project grows, things start to fall apart:
- Routes become a mess and nobody knows what each endpoint actually does
- No validation → the server accepts whatever the client throws at it
- Changing the response format means breaking all existing clients
- Authentication copy-pasted everywhere — fix one place, miss another
I once refactored a 50K-line codebase. The biggest lesson: you need solid test coverage before you start. The second lesson, equally painful: you need to design your API structure correctly from day one. Refactoring an API while clients are actively using it is extremely risky — a small breaking change can crash the entire mobile app for your users.
Core Concepts You Need to Understand
REST Is More Than Just HTTP + JSON
Many APIs call themselves “RESTful” when they’re really just JSON over HTTP. REST (Representational State Transfer) has several principles that are commonly overlooked:
- Resource-based URLs:
/users/123instead of/getUser?id=123 - Proper HTTP verbs: GET to read, POST to create, PUT/PATCH to update, DELETE to remove
- Stateless: Each request must be self-contained and not depend on server-side session state
- Consistent response structure: Always return the same format whether the request succeeds or fails
Versioning — The Most Commonly Skipped Step
There are three common ways to version an API: URL path (/api/v1/), query string (?version=1), and HTTP header (Accept: application/vnd.api+json;version=1). I use URL path. The reason is simple: it’s easy to read, easy to debug, and tools like Postman or curl work out of the box without extra configuration.
In Practice: Building a Proper API from Scratch
Project Structure
Start with a clear folder structure — here’s what I typically use:
project/
├── src/
│ ├── routes/
│ │ └── v1/
│ │ ├── users.js
│ │ └── posts.js
│ ├── middleware/
│ │ ├── auth.js
│ │ └── validate.js
│ ├── controllers/
│ ├── models/
│ └── app.js
├── package.json
└── .env
Setting Up Express with Versioning from the Start
// src/app.js
const express = require('express');
const app = express();
app.use(express.json());
// Mount v1 routes
const v1Router = require('./routes/v1');
app.use('/api/v1', v1Router);
// When a breaking change is needed, add v2 without affecting v1
// const v2Router = require('./routes/v2');
// app.use('/api/v2', v2Router);
module.exports = app;
// src/routes/v1/index.js
const router = require('express').Router();
router.use('/users', require('./users'));
router.use('/posts', require('./posts'));
module.exports = router;
Input Validation with Middleware
Don’t validate inside the controller — extract it into a separate middleware for reusability. I typically use the joi or zod library:
npm install joi
// src/middleware/validate.js
const Joi = require('joi');
const validate = (schema) => (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false });
if (error) {
return res.status(400).json({
success: false,
errors: error.details.map(d => d.message)
});
}
req.validatedBody = value;
next();
};
module.exports = validate;
// src/routes/v1/users.js
const router = require('express').Router();
const Joi = require('joi');
const validate = require('../../middleware/validate');
const auth = require('../../middleware/auth');
const createUserSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
name: Joi.string().min(2).max(50).required()
});
router.post('/', validate(createUserSchema), async (req, res) => {
// req.validatedBody has already been sanitized
const { email, name, password } = req.validatedBody;
// ... create user
});
router.get('/me', auth, async (req, res) => {
// Route requires authentication
});
module.exports = router;
JWT Authentication
npm install jsonwebtoken bcryptjs
// src/middleware/auth.js
const jwt = require('jsonwebtoken');
const auth = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, message: 'No authentication token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ success: false, message: 'Token is invalid or has expired' });
}
};
module.exports = auth;
Generating the token on login:
// Inside the login controller
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
async function login(req, res) {
const { email, password } = req.validatedBody;
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ success: false, message: 'Invalid email or password' });
}
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
success: true,
data: { token, user: { id: user.id, name: user.name, email: user.email } }
});
}
Consistent Response Format
The frontend team will hate you if every endpoint returns a different format. A simple rule: always include success, always include either data or message, and for validation errors include an errors array so the client can display per-field messages:
// Success
{
"success": true,
"data": { ... },
"meta": { "page": 1, "total": 50 } // for pagination
}
// Error
{
"success": false,
"message": "User not found",
"errors": ["email already exists", "password too short"] // optional
}
Create a helper so you don’t have to rewrite this every time:
// src/utils/response.js
exports.ok = (res, data, meta) =>
res.json({ success: true, data, ...(meta && { meta }) });
exports.fail = (res, status, message, errors) =>
res.status(status).json({ success: false, message, ...(errors && { errors }) });
Centralized Error Handling
Add a global error handler at the end of app.js to catch unhandled errors:
// At the end of src/app.js
app.use((err, req, res, next) => {
console.error(err.stack);
const status = err.status || 500;
res.status(status).json({
success: false,
message: status === 500 ? 'Internal server error' : err.message
});
});
Things Only Production Can Teach You
Code that runs on localhost is one thing. Here are a few points I only truly understood after production taught me the hard way:
- Never expose stack traces to the client — log on the server but only return a generic message for 500 errors
- Rate limiting is non-negotiable — use
express-rate-limit, configure a maximum of 100 requests per 15 minutes on auth endpoints to block brute force attacks - Log requests and responses with
morganfrom day one — when production breaks at 3am, logs are the only thing that can save you - Test endpoints with a Postman collection and commit it to the repo — new team members onboarding will thank you
- Write unit tests for your validate and auth middleware first — these are the most critical parts; a bug here affects the entire API
One more thing about versioning: when you add v2, don’t immediately remove v1. Keep v1 running for at least 3–6 months, communicate the deprecation clearly, and give clients enough time to migrate. I did this right and avoided a lot of complaints. Simple, but easy to overlook.
Node.js + Express is still the combo I trust for REST APIs — flexible enough, large ecosystem, and deployable anywhere. What makes the difference isn’t the framework — it’s careful design from the start instead of leaving it for a painful refactor later.
