Building Efficient RESTful APIs with Node.js and Express: Design, Authentication, and Versioning

Development tutorial - IT technology blog
Development tutorial - IT technology blog

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/123 instead 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 morgan from 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.

Share: