Skip to main content

3. Express.js - Basic To Advance

Cors , Custom Middleware ,Error Handlers, Api Versing , Rate Limiting

Updated 10 min readMarkdown
  1. What is Express.js ?

    • Express.js is a minimal & flexible web application framework built on top of Node.js.

    • It simplifies the process of building web servers, APIs, and backend applications.

    • Instead of writing raw http server code in Node.js, Express provides an easier syntax, routing, and middleware system.

  1. Installation and Setup

     npm init -y        # Initialize project
     npm install express
    
  2. Create Server from express.js

     //in index.js -> main file
     const express = require('express'); // 1. get the express module
     const app = express(); // 2. invoke method
     const PORT = 3000;
    
     app.get('/', (req, res) => {  // default raout
       res.send('Hello from Express.js!');
     });
    
     app.listen(PORT, () => {  // listen server
       console.log(`Server running at http://localhost:${PORT}`);
     });
    
     // This is app gives many methods like - app.post() , app.get() , app.Router(express.Router), app.use()
    
  3. Routing

//Express provides methods for handling different HTTP methods:
app.get('/user', (req, res) => res.send('GET request'));
app.post('/user', (req, res) => res.send('POST request'));
app.put('/user', (req, res) => res.send('PUT request'));
app.delete('/user', (req, res) => res.send('DELETE request'));

app.get('/user/:id', (req, res) => {  // we pass id in the req params
  res.send(`User ID is ${req.params.id}`);
});
  1. Middlewares

    Middleware functions are functions that run before the request is sent to the final route handler.

    Types:

    1. Application-level (for entire app)

    2. Router-level

    3. Built-in (like express.json())

    4. Error-handling

       //import express module first
       // Built-in middleware
       app.use(express.json());
      
       // Custom middleware
       app.use((req, res, next) => {  // we also make this callback function outside(best practice)
         console.log(`Request URL: ${req.url}`);
         next();  // Move to next middleware/route
       });
       // Now on evry request my custom middleware runs cuz it is for entire app
       // Router level: that runs only for particular route - like auth middleware
      
  1. Request & Response

    • req.body → data from POST/PUT

    • req.params → route parameters

    • req.query → query string

    • res.send(), res.json(), res.status()

  1. Error Handling

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send('Something broke!');
});
  1. REST Api Example with express

const express = require('express');
const app = express();
app.use(express.json());

let users = [{ id: 1, name: 'Ayush' }];

app.get('/users', (req, res) => res.json(users));
app.post('/users', (req, res) => {
  users.push(req.body);
  res.status(201).json(req.body);
});

app.listen(3000, () => console.log('API running on port 3000'));
  1. Express with Template Engines

    Supports EJS, Pug, Handlebars for server-side rendering.

    EJS - Emmeded Javascript(EJS) is a simple templating language that lets you generate HTML markup with plain Javascript. It can help you to generate dynimic HTML pages in express application.

app.set('view engine', 'ejs');
app.get('/', (req, res) => res.render('index', { name: 'Ayush' }));
  1. Real-World Uses of Express

    • REST APIs

    • GraphQL API layer

    • Authentication servers

    • Microservices

    • Full-stack apps (with React, Angular, Vue)

    • Middleware APIs (proxy, logging, monitoring)

  1. Alternative to express

    • Fastify → Faster, similar syntax.

    • Koa → From same team, middleware-centric.

    • NestJS → Full framework, TypeScript-first.

EXPRESS.JS INTERMEDIATE CONCEPTS

  1. CORS

    CORS (Cross-Origin Resource Sharing) is a browser security policy that controls whether a web page at Origin A (scheme + host + port, e.g. https://app.example.com:443) is allowed to read responses from Origin B (e.g. http://api.localhost:3000).

    • Same origin? No CORS involved.

    • Cross origin? Browser enforces CORS:

CORS is not auth. It only decides if the browser can read the response.

const cors = require('cors')
const configureCore = ()=>{
    return cors({
        // origin: controls which origins (domains) are allowed to access your API
        // It can be a string, array, or a function for dynamic checks.
         origin:(origin,callback)=>{
            const allowedOrigin =[
                'http://localhost:3000', // local development frontend
                'https://yourcustomdomain.com' // production domain frontend
            ]

            if(!origin || allowedOrigin.indexOf(origin) !==-1){
                callback(null , true); // giving permission so that request can be allowed
            }
            else{
                callback(new Error("Not allowed by cors")) //reject request (browser blocks)
            }
        },
        //methods -> which http methods you are allowing user to do the request
        methods: ['GET' , 'POST' , 'PUT' , 'DELETE'],

        // allowedHeaders: request headers the client is permitted to send
        // Example: if frontend sends Authorization header, it must be declared here
        allowedHeaders: [
            'Content-Type', // required for JSON body requests
            'Authorization',//// needed if using JWT/bearer tokens
            'Accept-Version'
        ],

        // exposedHeaders: response headers that the browser is allowed to read
        // Without this, custom headers (like X-Total-Count) are invisible to JS on frontend
        exposedHeaders:['X-Total-Count' , 'Content-Range'],
        credentials: true,// enales support for cookies and authoraiation geaders -> imp one

        // preflightContinue: if false, Express automatically ends OPTIONS preflight request
        // If true, you must handle OPTIONS response yourself
        preflightContinue:false,
        maxAge:600, //cache pre flight responses for 10mins(600 sec) -> help us to avoid sending options request multiple times
        optionsSuccessStatus:204  // provide status code to use sucessful options request , Some old browsers expect 200, default is 204 (No Content)
    })
}

module.exports = configureCore;

// use this in our main index.js
app.use(configureCore())

What happens if we don’t use CORS middleware at all?

  • If your server doesn’t respond with the proper CORS headers (Access-Control-Allow-Origin, etc.), the browser blocks the frontend JS from reading the response.

  • The request reaches your backend and executes normally, but the frontend cannot access the response (it’s dropped at the browser level).

  • Example: In Chrome DevTools you’ll see a CORS error like:

      Access to fetch at 'http://api.localhost:3000' from origin 'http://localhost:5173' 
      has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header present.
    

What happens if we allow all origins (origin: '*')?

  • Any website can make requests to your API from a browser.

  • ✅ Good for public APIs (e.g. weather data, open maps, RSS feeds).

  • ❌ Bad for APIs with sensitive data because:

    • Anyone can embed your API in their website and trick users into making requests.
┌─────────────────────────────────────────────────────────────┐
│            Browser (Frontend: http://localhost:3000)        │
└─────────────────────────────────────────────────────────────┘
                 │
                 │ 1. User makes a fetch() call:
                 │    fetch("http://api.localhost:4000/data", {
                 │      method: "POST",
                 │      headers: { "Content-Type": "application/json" }
                 │    })
                 │
                 ▼
┌─────────────────────────────────────────────────────────────┐
│                 Browser CORS Engine (Security)              │
└─────────────────────────────────────────────────────────────┘
                 │
                 │ 2. Sees it's a cross-origin request
                 │    (http://localhost:3000 → http://localhost:4000)
                 │
                 │ 3. Since method=POST with JSON → NOT "simple"
                 │    → Trigger **preflight OPTIONS** request
                 │
                 ▼
┌─────────────────────────────────────────────────────────────┐
│              Server (API: http://localhost:4000)            │
└─────────────────────────────────────────────────────────────┘
                 │
                 │ 4. Browser sends:
                 │    OPTIONS /data
                 │    Origin: http://localhost:3000
                 │    Access-Control-Request-Method: POST
                 │    Access-Control-Request-Headers: content-type
                 │
                 ▼
┌─────────────────────────────────────────────────────────────┐
│                   Server CORS Middleware (Express)          │
└─────────────────────────────────────────────────────────────┘
                 │
                 │ 5. Server checks origin against allowlist
                 │    → If allowed, replies:
                 │       Access-Control-Allow-Origin: http://localhost:3000
                 │       Access-Control-Allow-Methods: GET,POST,PUT,DELETE
                 │       Access-Control-Allow-Headers: content-type
                 │       Access-Control-Allow-Credentials: true
                 │
                 ▼
┌─────────────────────────────────────────────────────────────┐
│              Browser CORS Engine (Security)                 │
└─────────────────────────────────────────────────────────────┘
                 │
                 │ 6. Browser inspects response headers
                 │    → If allowed → continues
                 │    → If missing headers → BLOCKS request
                 │
                 │ 7. Sends the **actual POST request**:
                 │    POST /data
                 │    Origin: http://localhost:3000
                 │    Content-Type: application/json
                 │
                 ▼
┌─────────────────────────────────────────────────────────────┐
│                        Server API                           │
└─────────────────────────────────────────────────────────────┘
                 │
                 │ 8. Server responds with data + CORS headers:
                 │    HTTP/1.1 200 OK
                 │    Access-Control-Allow-Origin: http://localhost:3000
                 │    Content-Type: application/json
                 │    { "message": "success" }
                 │
                 ▼
┌─────────────────────────────────────────────────────────────┐
│             Browser JavaScript (Frontend App)               │
└─────────────────────────────────────────────────────────────┘
                 │
                 │ 9. Browser delivers the JSON response
                 │    to the fetch() promise
                 │
                 ▼
               ✅ SUCCESS → frontend can use the data
  1. Custom Middlewares


const requestLogger = (req,res, next)=>{
    const timeStamp = new Date().toISOString();
    const method = req.method;
    const url = req.url
    const userAgent = req.get("User-Agent")
    console.log(timeStamp , method , url ,userAgent) // On every api hit in post man then this will print in the our console
    next();
}
const addTimeStamp = (req,res,next)=>{
    req.timeStamp = new Date().toISOString();
    next();
}
module.exports = {requestLogger , addTimeStamp}

// In server.js(main file)
// these middle called on every request cuz they decalre as global level
app.use(requestLogger)
app.use(addTimeStamp)
app.use(configureCore())
app.use(express.json())

When to use custom middlewares ?

  • Authentication/Authorization

  • Logging

  • Validation

  • Error handling

  • Rate limiting

  • Data transformation

  • Custom headers

Error Handler Middleware -:

  1. Request → Route runs inside asyncHandler.

  2. If error → next(err) forwards to globalErrorHandler.

  3. globalErrorHandler decides:

    • Custom APIError → use its statusCode & message.

    • ValidationError → return 400.

    • Else → return 500.


// custom error class
//By creating APIError, we can attach statusCode and handle API-specific errors properly.
class APIError extends Error {

    constructor(message , statusCode){
        super(message) // call to parent class
        this.statusCode = statusCode; //// Custom property to store HTTP status (e.g., 400, 404, 500)
        this.name = "APIError" // Set custom error name (helps in distinguishing error types)
    }
}

const asyncHandler = (fn) => (req,res , next)=>{
    // Wraps the async function and ensures any rejected Promise is passed to "next()"
    // Without this, you'd need try/catch in every route
    // With asyncHandler, any thrown error → .catch(next) → forwarded to global error handler.
    Promise.resolve(fn(req,res,next)).catch(next)
}

//A global error handler ensures all errors are caught and consistent responses are returned.
const globalErrorHandler = (err , req , res, next)=>{
    console.log(err.stack)
    if(err instanceof APIError){
        return res.status(err.statusCode).json({
        status:"Error",
        message: err.message
    })
    }
    //also handle mongoose validation
    else if (err.name === 'validationError'){
        return res.status(400).json({
            status:'error',
            message:"validation error"
        })
    }
// Other unknown erros
    else{
        return res.status(500).json({
            status:'error',
            message:"Internal Server error"
        })
    }

}
module.exports = {APIError , asyncHandler , globalErrorHandler}

In Main Server.js

//--- other code ---
app.use(express.json())

// make dummy route
app.get("/user/:id", asyncHandler(async (req, res) => {
    const userId = req.params.id;
    if(userId !== "1"){
        // Throw custom error if user not found
        throw new APIError("User not found", 404);
    }
    res.json({ id: 1, name: "Ayush" });
}));

// Always place the error handler LAST
app.use(globalErrorHandler)
  1. API Versing

    APi Versing help us whether the particular API URL is actually starting with a particular version or not like v1 , v2

    API versioning is a way to maintain multiple versions of your API simultaneously.
    It helps when:

    • You want to release new features without breaking old clients.

    • You need to deprecate old endpoints gradually.

    • Different clients (web, mobile, third-party apps) depend on different API behaviors.

Ways to do API Versing:

    // 🔹 Way 1: Path/URL-based versioning
    // Middleware factory → returns a middleware that checks if request path starts with /api/{version}
    const apiVersioning = (version) => (req, res, next) => {
        // Example: version = 'v1' → check if path starts with /api/v1
        if (req.path.startsWith(`/api/${version}`)) {
            next(); // ✅ allow request to continue
        } else {
            // ❌ version not supported → send error response
            res.status(404).json({
                success: false,
                error: "API Version is not supported"
            })
        }
    }

    //in main server.js
    app.use(apiVersioning('v1')); //// now only requests like /api/v1/users will pass
    // 🔹 Way 2: Header-based versioning
    // Middleware factory → checks if request contains "Accept-Version" header with correct version
    const headerVersioning = (version) => (req, res, next) => {
        // Example: Accept-Version: v1
        if (req.get('Accept-Version') === version) {
            next(); // ✅ correct version → allow
        } else {
            res.status(404).json({
                success: false,
                error: "API Version is not supported"
            })
        }
    }
    // Server.js
    app.use("/api/users", headerVersioning("v2"), userRoutes);
    // client must send → headers: { "Accept-Version": "v2" }
    // 🔹 Way 3: Content-Type negotiation based versioning
    // Middleware factory → checks if Content-Type header matches custom media type pattern
    const contentTypeVersioning = (version) => (req, res, next) => {
        const contentType = req.get('Content-Type'); 
        // Example: Content-Type: application/vnd.api.v1+json
        if (contentType && contentType.includes(`application/vnd.api.${version}+json`)) {
            next(); // ✅ correct version → allow
        } else {
            res.status(404).json({
                success: false,
                error: "API Version is not supported"
            })
        }
    }
    // Server.js
    app.use("/api/users", contentTypeVersioning("v1"), userRoutes);
    // client must send header → Content-Type: application/vnd.api.v1+json
  1. Rate Limiting

    Rate limiting is a way to control how many requests a client can make to your API in a given time window.
    It helps to:

    • ✅ Protect your server from DDoS attacks or brute force attacks

    • ✅ Prevent abuse of APIs (e.g., login spamming)

    • ✅ Ensure fair usage among users

    • ✅ Reduce server load

npm install express-rate-limit

Make custom middleware - rateLimiting.js

//ratelimiting.js
const rateLimit = require('express-rate-limit')
const createBasicRateLimiter = (maxRequets , time)=>{
    return rateLimit({
        max:maxRequets,
        windowMs:time,
        message:"Too many requests, please try again later",
        standardHeaders:true, // Return rate limit info in the `RateLimit-*` headers
        legacyHeaders:false, // Disable the `X-RateLimit-*` headers
    })
}
module.exports = createBasicRateLimiter;

//In server.js
app.use(createBasicRateLimiter(100 , 15*60*1000)) // 100 req per 15 minutes
app.use(express.json())

Final Server.js look like this

const express = require('express')
const app = express();
require('dotenv').config();
const cors = require('cors');
const configureCore = require('./config/corsConfig');
const { requestLogger, addTimeStamp } = require('./middlewares/customMiddleware');
const { globalErrorHandler, asyncHandler } = require('./middlewares/errorHandler');
const apiVersioning = require('./middlewares/apiVersioning');
const createBasicRateLimiter = require('./middlewares/rateLimiting');
const PORT = process.env.PORT ||5000
const itemsRoutes = require('./routes/item-routes')

// these middle called on every request cuz they decalre as global level
app.use(requestLogger)
app.use(addTimeStamp)
app.use(configureCore())
app.use(createBasicRateLimiter(100 , 15*60*1000)) // 100 req per 15 minutes
app.use(express.json())

// Apply versioning middleware FIRST
app.use(apiVersioning('v1'));
app.use('/api/v1', itemsRoutes) //http://localhost:3000/api/v2/items -> when you call this then version error came

// make dummy route
app.get("/user/:id", asyncHandler(async (req, res) => {
    const userId = req.params.id;
    if(userId !== "1"){
        // Throw custom error if user not found
        throw new APIError("User not found", 404);
    }
    res.json({ id: 1, name: "Ayush" });
}));
// Always place the error handler LAST
app.use(globalErrorHandler)

app.listen(PORT , ()=>{
    console.log("Server is running at PORT" , PORT);
})

No comments yet. Be the first to comment.

More from this blog