Node.js & APIs
Designing REST APIs with Express.js and JWT Authentication
Resource-oriented routes, consistent errors and a small JWT middleware: a practical blueprint for Node.js APIs that frontends enjoy consuming.
A REST API is a contract with every client that calls it. When the contract is consistent, frontend work speeds up; when it is not, every screen needs special cases. Here is the blueprint I follow with Express.js.
Name resources, not actions
Routes describe things, and HTTP methods describe what happens to them.
GET /api/bookings list bookings
POST /api/bookings create a booking
GET /api/bookings/:id read one booking
PATCH /api/bookings/:id update part of a booking
DELETE /api/bookings/:id cancel a bookingVerbs such as /createBooking or /getAllBookings creep in easily. Resisting them keeps the API guessable.
One error shape everywhere
Clients should never parse three different error formats. A single error handler at the end of the middleware chain makes that easy:
app.use((err, req, res, next) => {
const status = err.status || 500;
res.status(status).json({
error: { message: err.expose ? err.message : "Something went wrong", code: err.code || "INTERNAL" },
});
});Authenticate with a small JWT middleware
After login, the server signs a short-lived token. Protected routes verify it before doing anything else.
import jwt from "jsonwebtoken";
export function requireAuth(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: { message: "Missing token", code: "UNAUTHENTICATED" } });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: { message: "Invalid or expired token", code: "UNAUTHENTICATED" } });
}
}Authorise by role, separately
Authentication answers who are you; authorisation answers what may you do. Keeping them in separate middleware keeps both readable — and is the foundation of role-based access control in systems like an HRMS.
export const requireRole = (...roles) => (req, res, next) =>
roles.includes(req.user?.role) ? next() : res.status(403).json({ error: { message: "Forbidden", code: "FORBIDDEN" } });
router.patch("/payroll/:id", requireAuth, requireRole("admin", "hr"), updatePayroll);Validate at the edge
Reject bad input before it reaches the database. Validate the body, params and query of every write request, and return a 400 with field-level messages the frontend can display next to the right input.
A checklist before shipping
- Every list endpoint is paginated.
- Secrets live in environment variables, never in the repository.
- Tokens expire, and passwords are hashed.
- Errors share one shape and never leak stack traces.
- CORS allows only the origins that need access.
Small, boring conventions like these are what make an API pleasant to build against for years.