Available for Hire
ZB

Memuat...

Back to Blog
Backend 6 min read Β· 1263 words

Building a REST API with ExpressJS and MongoDB

Assembling a REST API worth shipping: project structure, input validation, centralised error handling, JWT authentication, and the security details that usually get missed.

#nodejs #expressjs #mongodb #api #backend

Building a REST API that works is easy. Building one that is still pleasant to maintain six months later and does not leak data is another matter. This walkthrough assembles an Express and MongoDB API with the emphasis on the parts that usually only start to matter once real people are using it.

Project Setup

mkdir my-api && cd my-api
npm init -y
npm install express mongoose dotenv bcryptjs jsonwebtoken zod helmet express-rate-limit
npm install -D nodemon

The last three packages are often skipped even though they are cheap to add: zod for validation, helmet for security headers, and express-rate-limit for throttling requests.

Folder Structure

src/
β”œβ”€β”€ config/        # Database connection, env parsing
β”œβ”€β”€ models/        # Mongoose schemas
β”œβ”€β”€ controllers/   # Request and response handling
β”œβ”€β”€ services/      # Business logic
β”œβ”€β”€ routes/        # Endpoint definitions
β”œβ”€β”€ middleware/    # Auth, validation, error handling
└── index.js

Separating controllers from services feels excessive at first, but it pays off the moment the same logic is needed elsewhere, for example in a CLI command or a scheduled job. A controller should only deal with HTTP: read the request, call the service, send the response. The service should know nothing about HTTP at all.

Configuration That Fails Early

An application that boots with half-configured settings will fail somewhere confusing later. Better to stop right at startup:

// src/config/env.js
import { z } from 'zod';

const schema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().default(3000),
  MONGODB_URI: z.string().url(),
  JWT_SECRET: z.string().min(32),
});

const parsed = schema.safeParse(process.env);

if (!parsed.success) {
  console.error('Invalid configuration:', parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;

The min(32) requirement on JWT_SECRET is not decoration. A short secret can be brute forced, and a forgeable JWT means anyone can impersonate any user.

The User Model

import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';

const userSchema = new mongoose.Schema({
  name: { type: String, required: true, trim: true },
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    trim: true,
    index: true,
  },
  password: { type: String, required: true, select: false },
  role: { type: String, enum: ['user', 'admin'], default: 'user' },
}, { timestamps: true });

userSchema.pre('save', async function () {
  if (!this.isModified('password')) return;
  this.password = await bcrypt.hash(this.password, 12);
});

userSchema.methods.comparePassword = function (candidate) {
  return bcrypt.compare(candidate, this.password);
};

export default mongoose.model('User', userSchema);

Two details here matter.

select: false on the password keeps that field out of ordinary queries. This is a safety net against the most common beginner mistake in an API: sending the password hash to the client because someone forgot to strip the field. When you genuinely need it, call .select('+password') explicitly.

The pre('save') hook guarantees the password is always hashed, wherever a user is created. Putting the hashing in a controller means you have to remember to do it everywhere, and one day somebody will not. The number 12 is the bcrypt work factor, a reasonable compromise between security and computation time.

Validating Input

Never trust the contents of a request. Validate in one place through middleware:

export const validate = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      error: 'Validation failed',
      details: result.error.flatten().fieldErrors,
    });
  }
  req.body = result.data; // Only fields that passed the schema
  next();
};

The line req.body = result.data is important. It discards unknown fields, so an attacker cannot slip role: "admin" into a registration request and have it persisted through mass assignment.

CRUD Endpoints

import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../middleware/validate.js';
import { auth, requireRole } from '../middleware/auth.js';
import * as controller from '../controllers/user.controller.js';

const router = Router();

const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  password: z.string().min(8),
});

router.get('/', auth, controller.list);
router.get('/:id', auth, controller.getById);
router.post('/', validate(createUserSchema), controller.create);
router.patch('/:id', auth, controller.update);
router.delete('/:id', auth, requireRole('admin'), controller.remove);

export default router;

Note that PATCH is used for partial updates rather than PUT. Semantically PUT means replacing the entire resource, which is rarely what you actually want.

Always Paginate Lists

An endpoint that returns an entire collection will be fine with fifty rows, then take the server down when the data reaches half a million.

export async function list(req, res) {
  const page = Math.max(1, Number(req.query.page) || 1);
  const limit = Math.min(100, Number(req.query.limit) || 20);

  const [items, total] = await Promise.all([
    User.find().skip((page - 1) * limit).limit(limit).lean(),
    User.countDocuments(),
  ]);

  res.json({
    data: items,
    meta: { page, limit, total, pages: Math.ceil(total / limit) },
  });
}

The upper bound on limit stops anyone requesting a million rows at once. The .lean() method returns plain JavaScript objects instead of full Mongoose documents, and on long lists the speed difference is noticeable.

Authentication Middleware

import jwt from 'jsonwebtoken';
import { env } from '../config/env.js';

export function auth(req, res, next) {
  const header = req.header('Authorization') || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;

  if (!token) {
    return res.status(401).json({ error: 'Token not found' });
  }

  try {
    req.user = jwt.verify(token, env.JWT_SECRET);
    next();
  } catch {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

export const requireRole = (role) => (req, res, next) => {
  if (req.user?.role !== role) {
    return res.status(403).json({ error: 'Access denied' });
  }
  next();
};

An invalid token should return 401, not 400. A 401 means β€œyou have not proven who you are”, while 403 means β€œwe know who you are, and you are not allowed”. Distinguishing the two makes it easy for a client to decide whether to prompt for login again.

Put only identity and role in the JWT payload. Never store sensitive data there, because a JWT payload is base64 encoded, not encrypted, and anyone can read it.

Centralised Error Handling

Writing try/catch in every controller gets tedious fast and is easy to forget. Recent versions of Express forward errors from async functions to the error handler automatically:

export function errorHandler(err, req, res, next) {
  console.error(err);

  if (err.name === 'ValidationError') {
    return res.status(400).json({ error: 'Invalid data' });
  }
  if (err.code === 11000) {
    return res.status(409).json({ error: 'Resource already exists' });
  }

  res.status(err.status || 500).json({
    error: env.NODE_ENV === 'production' ? 'Something went wrong' : err.message,
  });
}

MongoDB’s code 11000 means a unique index violation, for example an email that is already registered. Translating that into a 409 is far more useful to a client than an unexplained 500.

Note as well that error messages are hidden in production. Stack traces and internal messages can leak your database structure and file paths to an attacker.

Basic Security Layers

import helmet from 'helmet';
import rateLimit from 'express-rate-limit';

app.use(helmet());
app.use(express.json({ limit: '10kb' }));

app.use('/api/auth', rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 20,
  message: { error: 'Too many attempts, try again later' },
}));

The 10kb cap on JSON bodies stops enormous requests from exhausting memory. Rate limiting specifically on authentication endpoints makes password guessing far more expensive.

One more thing that is easy to miss: make sure .env is in .gitignore from the very first commit. A secret that has ever entered Git history can still be recovered even after the file is deleted, and the only real fix is rotating that secret.

Closing

What separates a practice API from one worth shipping is not the number of endpoints, it is what happens when something goes off script: strange input, an expired token, a dropped database connection, or someone sending ten thousand requests a minute. Build the answers to those conditions in from the start, because adding them later is always far more expensive.

Share this article:

Enjoyed this article?

0 reactions