Module 16: JavaScript Backend

The Router Object and Modularization in Express.js

The Need for Modularization

As Express applications grow, keeping all routes in a single file becomes unwieldy and difficult to maintain. Modularization helps organize routes logically, improves code readability, and enables better collaboration in development teams.

Analogy: Think of a large Express application like a sprawling city. Without organization, it becomes a chaotic maze where finding anything is difficult. Modularization is like dividing the city into distinct neighborhoods and districts, each with its own purpose and internal organization, connected by a clear transportation system. This makes navigation intuitive and maintenance manageable.

graph TD A[Monolithic App] --> B[All Routes in app.js] B --> C[Hard to Navigate] B --> D[Difficult to Maintain] B --> E[Team Collaboration Issues] F[Modularized App] --> G[Router Modules] G --> H[Clear Organization] G --> I[Easier Maintenance] G --> J[Better Collaboration]

Symptoms of a need for modularization include:

Introduction to the Express Router

The Express Router is a mini Express application that provides routing capabilities without all the app-level functionality. It allows you to create modular, mountable route handlers.


// Create a router instance
const express = require('express');
const router = express.Router();

// Define routes on the router
router.get('/', (req, res) => {
  res.send('Home page');
});

router.get('/about', (req, res) => {
  res.send('About page');
});

// Export the router
module.exports = router;
            

Using the Router in Your Application


// In your main app.js file
const express = require('express');
const app = express();
const routes = require('./routes');

// Mount the router at the root path
app.use('/', routes);

// Start the server
app.listen(3000, () => {
  console.log('Server running on port 3000');
});
            

This simple example demonstrates the basic pattern: create a router, define routes on it, export it, and mount it in your main application.

flowchart TB subgraph "App Structure" direction TB A[app.js] --> B[Mount Routers] B --> C[userRouter] B --> D[productRouter] B --> E[authRouter] C --> F[GET /users] C --> G[POST /users] C --> H[GET /users/:id] D --> I[GET /products] D --> J[POST /products] D --> K[GET /products/:id] E --> L[POST /login] E --> M[POST /register] E --> N[POST /logout] end

Real-world example: Large-scale platforms like LinkedIn use Express Router extensively to organize their API endpoints. With thousands of endpoints across various domains (profiles, messaging, job postings, etc.), they structure their codebase by creating separate router modules for each domain, making it far easier to locate and maintain specific functionality.

Router Basics and Methods

The Router object supports the same routing methods as the app object:


// All HTTP methods available on the router
router.get('/users', getAllUsers);
router.post('/users', createUser);
router.put('/users/:id', updateUser);
router.delete('/users/:id', deleteUser);
router.patch('/users/:id', partialUpdateUser);
router.head('/users', getUsersHeaders);
router.options('/users', getUsersOptions);

// Generic routing method
router.all('/users/*', userAccessLogger);
            

Method Chaining with Router

Just like with the app object, you can chain methods on router.route():


// Method chaining for the same path
router.route('/users/:userId')
  .get((req, res) => {
    res.json({ /* user data */ });
  })
  .put((req, res) => {
    res.json({ message: 'User updated' });
  })
  .delete((req, res) => {
    res.json({ message: 'User deleted' });
  });
            

Router Parameters

The router object has its own param() method, which works like app.param() but is scoped to the router:


// Define parameter handling
router.param('userId', (req, res, next, userId) => {
  // Find user in database
  const user = findUserById(userId);
  
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  
  // Attach to request
  req.user = user;
  next();
});

// Use parameter in routes
router.get('/:userId', (req, res) => {
  // req.user is already populated
  res.json(req.user);
});

router.put('/:userId', (req, res) => {
  // Update req.user
  Object.assign(req.user, req.body);
  saveUser(req.user);
  res.json(req.user);
});
            

The param() method is particularly valuable in routers because it allows parameter processing specific to that router's domain.

Router Mounting

One of the most powerful features of the Router is the ability to mount it at a specific path prefix, which becomes the base path for all routes defined on the router.

Basic Router Mounting


// userRoutes.js
const express = require('express');
const router = express.Router();

router.get('/', (req, res) => {
  res.json({ message: 'All users' });
});

router.get('/:id', (req, res) => {
  res.json({ message: `User with ID: ${req.params.id}` });
});

module.exports = router;

// app.js
const express = require('express');
const userRoutes = require('./userRoutes');
const app = express();

// Mount the userRoutes router at /api/users
app.use('/api/users', userRoutes);

/*
This creates the following routes:
- GET /api/users       -> "All users"
- GET /api/users/:id   -> "User with ID: ..."
*/
            

Multiple Router Instances

You can create and mount multiple router instances for different sections of your application:


// In separate files:
// userRoutes.js
const userRouter = express.Router();
// ... define user routes
module.exports = userRouter;

// productRoutes.js
const productRouter = express.Router();
// ... define product routes
module.exports = productRouter;

// authRoutes.js
const authRouter = express.Router();
// ... define auth routes
module.exports = authRouter;

// app.js
const express = require('express');
const userRoutes = require('./userRoutes');
const productRoutes = require('./productRoutes');
const authRoutes = require('./authRoutes');
const app = express();

// Mount each router
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);
app.use('/auth', authRoutes);
            

Router Path Prefixes

When a router is mounted, its routes are prefixed with the mount path:

Router Mounting and Path Prefixes Express Application (app.js) userRouter GET / → All users GET /:id → User detail POST / → Create user productRouter GET / → All products GET /:id → Product detail POST / → Create product authRouter POST /login POST /register POST /logout Mount at /api/users Mount at /api/products Mount at /auth

Analogy: Router mounting is like assigning different departments to specific floors in an office building. Each department (router) handles its own internal organization, but the building's floor number (mount path) determines the complete address for any office. If Human Resources (a router) is on the third floor, all HR offices have "3rd Floor" as part of their address.

Router-Level Middleware

Routers can have their own middleware that applies only to the routes defined on that router. This creates middleware scopes tied to specific route groups.


// Router-specific middleware
const express = require('express');
const router = express.Router();

// Logger middleware specific to this router
router.use((req, res, next) => {
  console.log(`${req.method} ${req.originalUrl} - ${new Date().toISOString()}`);
  next();
});

// Authentication middleware specific to this router
router.use((req, res, next) => {
  const apiKey = req.headers['x-api-key'];
  
  if (!apiKey || apiKey !== 'valid-key') {
    return res.status(401).json({ error: 'API key required' });
  }
  
  next();
});

// Routes defined after middleware will use the middleware
router.get('/', (req, res) => {
  res.json({ message: 'Protected data' });
});

router.post('/', (req, res) => {
  res.json({ message: 'Data created in protected area' });
});

module.exports = router;
            

Path-Specific Middleware in Routers

You can also apply middleware to specific paths within a router:


// Admin router with different authentication levels
const adminRouter = express.Router();

// Base middleware for all admin routes
adminRouter.use((req, res, next) => {
  console.log('Admin area accessed');
  next();
});

// Middleware for specific admin section
adminRouter.use('/users', (req, res, next) => {
  // Check for user management permission
  if (!req.user.permissions.includes('manage_users')) {
    return res.status(403).json({ error: 'User management permission required' });
  }
  next();
});

adminRouter.use('/settings', (req, res, next) => {
  // Check for settings permission
  if (!req.user.permissions.includes('manage_settings')) {
    return res.status(403).json({ error: 'Settings permission required' });
  }
  next();
});

// Regular routes
adminRouter.get('/dashboard', (req, res) => {
  res.json({ message: 'Admin dashboard' });
});

adminRouter.get('/users', (req, res) => {
  res.json({ message: 'User management' });
});

adminRouter.get('/settings', (req, res) => {
  res.json({ message: 'Site settings' });
});

// Mount in main app
app.use('/admin', authenticate, adminRouter);
            
flowchart TD subgraph App Auth[Authentication Middleware] end subgraph AdminRouter AdminLog[Admin Logger Middleware] subgraph UserSection UserPerm[User Permission Check] UserRoutes[User Management Routes] end subgraph SettingSection SettingPerm[Settings Permission Check] SettingRoutes[Settings Routes] end Dashboard[Dashboard Route] end App --> Auth Auth --> AdminRouter AdminRouter --> AdminLog AdminLog --> Dashboard AdminLog --> UserSection AdminLog --> SettingSection UserPerm --> UserRoutes SettingPerm --> SettingRoutes

Real-world example: E-commerce platforms like Shopify use router-level middleware to enforce different access rules for different sections of their admin dashboard. Store owners, staff members, and developers may all have access to the dashboard, but router-level middleware ensures they can only access the appropriate sections based on their permissions.

Organizing Routes by Resource

A common pattern is to organize routers by resource type, creating a separate router for each resource in your application.

Folder Structure


project/
├── app.js
├── routes/
│   ├── index.js         # Combines and exports all routes
│   ├── user.routes.js   # User-related routes
│   ├── product.routes.js # Product-related routes
│   ├── order.routes.js  # Order-related routes
│   └── auth.routes.js   # Authentication routes
├── controllers/         # Route handlers organized by resource
├── models/              # Data models
└── middleware/          # Custom middleware functions
            

Resource Router Example


// routes/user.routes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/user.controller');
const { authenticate, authorize } = require('../middleware/auth');

// Public routes
router.get('/', userController.getAllUsers);
router.get('/:id', userController.getUserById);

// Protected routes
router.post('/', authenticate, userController.createUser);
router.put('/:id', authenticate, authorize('admin'), userController.updateUser);
router.delete('/:id', authenticate, authorize('admin'), userController.deleteUser);

// Nested resource routes
router.get('/:id/posts', userController.getUserPosts);
router.post('/:id/posts', authenticate, userController.createUserPost);

module.exports = router;
            

Combining Routes in index.js


// routes/index.js
const express = require('express');
const userRoutes = require('./user.routes');
const productRoutes = require('./product.routes');
const orderRoutes = require('./order.routes');
const authRoutes = require('./auth.routes');

const router = express.Router();

// Mount each resource router
router.use('/users', userRoutes);
router.use('/products', productRoutes);
router.use('/orders', orderRoutes);
router.use('/auth', authRoutes);

module.exports = router;
            

Using the Combined Router in app.js


// app.js
const express = require('express');
const routes = require('./routes');

const app = express();

// Middleware setup...

// Mount all routes under /api
app.use('/api', routes);

// Error handlers...

app.listen(3000, () => {
  console.log('Server running on port 3000');
});
            

This approach creates a clean hierarchy:

Analogy: Organizing routes by resource is like a well-designed library. Each section (Fiction, Non-Fiction, Reference, etc.) has its own organization system, staff (middleware), and rules. The main library directory tells visitors which section to go to, and then each section handles its own internal organization.

API Versioning with Routers

Routers are excellent for implementing API versioning, allowing you to maintain multiple versions of your API simultaneously.


// routes/v1/users.js
const express = require('express');
const router = express.Router();

router.get('/', (req, res) => {
  res.json({ message: 'Users API v1' });
});

router.get('/:id', (req, res) => {
  res.json({ message: `User ${req.params.id} from API v1` });
});

module.exports = router;

// routes/v2/users.js
const express = require('express');
const router = express.Router();

router.get('/', (req, res) => {
  // New fields or format in v2
  res.json({ 
    message: 'Users API v2',
    data: [],
    meta: { version: 2 }
  });
});

router.get('/:id', (req, res) => {
  res.json({ 
    message: `User ${req.params.id} from API v2`,
    meta: { version: 2 }
  });
});

// New endpoint in v2
router.get('/:id/profile', (req, res) => {
  res.json({ message: 'User profile (v2 only)' });
});

module.exports = router;

// routes/index.js
const express = require('express');
const v1UserRoutes = require('./v1/users');
const v2UserRoutes = require('./v2/users');
// ... other route imports

const router = express.Router();

// Mount v1 routes
router.use('/v1/users', v1UserRoutes);
// ... other v1 routes

// Mount v2 routes
router.use('/v2/users', v2UserRoutes);
// ... other v2 routes

module.exports = router;
            

This creates endpoints like:

graph TB A[Express App] --> B[API Router] B --> C[v1 Router] B --> D[v2 Router] C --> E[v1 User Routes] C --> F[v1 Product Routes] D --> G[v2 User Routes] D --> H[v2 Product Routes] E --> I[GET /v1/users] E --> J[GET /v1/users/:id] G --> K[GET /v2/users] G --> L[GET /v2/users/:id] G --> M[GET /v2/users/:id/profile]

Real-world example: Payment processing API Stripe maintains multiple API versions simultaneously. They use a router-based structure to serve different versions (2020-08-27, 2022-11-15, etc.) of their API, allowing clients to upgrade at their own pace while ensuring backward compatibility for existing integrations.

Nested Routers

For complex applications, you can create hierarchies of routers by mounting one router within another.


// userPostsRouter.js
const express = require('express');
const postRouter = express.Router({ mergeParams: true }); // Important!

// This router handles /users/:userId/posts/*
postRouter.get('/', (req, res) => {
  // req.params.userId is accessible because of mergeParams: true
  res.json({ message: `All posts for user ${req.params.userId}` });
});

postRouter.get('/:postId', (req, res) => {
  res.json({ 
    message: `Post ${req.params.postId} for user ${req.params.userId}` 
  });
});

postRouter.post('/', (req, res) => {
  res.json({ 
    message: `Created post for user ${req.params.userId}` 
  });
});

module.exports = postRouter;

// userRouter.js
const express = require('express');
const userRouter = express.Router();
const postRouter = require('./userPostsRouter');

// User routes
userRouter.get('/', (req, res) => {
  res.json({ message: 'All users' });
});

userRouter.get('/:userId', (req, res) => {
  res.json({ message: `User ${req.params.userId}` });
});

// Mount the posts router for nested resources
userRouter.use('/:userId/posts', postRouter);

module.exports = userRouter;
            

The mergeParams: true option is crucial here—it allows the nested router to access parameters from parent routers.

Deep Router Nesting

You can create multiple levels of nesting for complex resource hierarchies:


// commentRouter.js
const express = require('express');
const commentRouter = express.Router({ mergeParams: true });

// This handles /users/:userId/posts/:postId/comments/*
commentRouter.get('/', (req, res) => {
  const { userId, postId } = req.params;
  res.json({ message: `Comments for post ${postId} by user ${userId}` });
});

commentRouter.post('/', (req, res) => {
  const { userId, postId } = req.params;
  res.json({ message: `Added comment to post ${postId} by user ${userId}` });
});

module.exports = commentRouter;

// Then in postRouter.js:
// ...
// Mount the comments router
postRouter.use('/:postId/comments', commentRouter);
            

This creates a three-level hierarchy:

Nested Routers Hierarchy User Router: /users Post Router: /:userId/posts Comment Router: /:postId/comments /users /users/:userId/posts /users/:userId/posts/:postId/comments

Router Factories

For applications with many similar resources, you can create router factories to generate consistent router structures:


// createResourceRouter.js
function createResourceRouter(resourceName, controller, options = {}) {
  const router = express.Router({ mergeParams: true });
  
  // Apply resource-specific middleware if provided
  if (options.middleware) {
    router.use(options.middleware);
  }
  
  // Set up standard CRUD routes
  router.get('/', controller.getAll);
  router.get('/:id', controller.getById);
  
  // Protected routes require authentication
  if (options.authenticate) {
    router.post('/', options.authenticate, controller.create);
    router.put('/:id', options.authenticate, controller.update);
    router.delete('/:id', options.authenticate, controller.delete);
  } else {
    router.post('/', controller.create);
    router.put('/:id', controller.update);
    router.delete('/:id', controller.delete);
  }
  
  // Set up nested resources if provided
  if (options.nestedResources) {
    options.nestedResources.forEach(nested => {
      router.use(
        `/:id/${nested.path}`,
        nested.router
      );
    });
  }
  
  console.log(`Created router for ${resourceName}`);
  return router;
}

// Using the factory
const userController = require('./controllers/user.controller');
const postController = require('./controllers/post.controller');
const { authenticate } = require('./middleware/auth');

// Create post router first (for nesting)
const postRouter = createResourceRouter('posts', postController, {
  authenticate
});

// Create user router with nested posts
const userRouter = createResourceRouter('users', userController, {
  authenticate,
  nestedResources: [
    { path: 'posts', router: postRouter }
  ]
});

// Mount in the app
app.use('/api/users', userRouter);
            

The factory approach ensures consistent routing patterns across resources and reduces code repetition.

Real-world example: Content management system Contentful uses router factories in their Node.js SDK to dynamically generate API endpoints for different content types. This allows their system to adapt to customer-defined content models while maintaining consistent RESTful patterns for all resources.

Testing Modular Routes

The modular structure of Express routers makes them easier to test in isolation:


// test/routes/user.routes.test.js
const request = require('supertest');
const express = require('express');
const userRouter = require('../../routes/user.routes');

describe('User Routes', () => {
  let app;
  
  beforeEach(() => {
    // Create a fresh Express app for each test
    app = express();
    
    // Apply necessary middleware
    app.use(express.json());
    
    // Mount the router under test
    app.use('/users', userRouter);
  });
  
  describe('GET /users', () => {
    it('should return all users', async () => {
      const response = await request(app)
        .get('/users')
        .expect('Content-Type', /json/)
        .expect(200);
        
      expect(response.body).toHaveProperty('data');
      expect(Array.isArray(response.body.data)).toBe(true);
    });
    
    it('should filter users by query parameters', async () => {
      const response = await request(app)
        .get('/users?role=admin')
        .expect(200);
        
      // Check that all returned users have admin role
      expect(response.body.data.every(user => user.role === 'admin')).toBe(true);
    });
  });
  
  describe('GET /users/:id', () => {
    it('should return a specific user', async () => {
      const userId = 'test-user-id';
      
      const response = await request(app)
        .get(`/users/${userId}`)
        .expect(200);
        
      expect(response.body.data.id).toBe(userId);
    });
    
    it('should return 404 for non-existent user', async () => {
      await request(app)
        .get('/users/nonexistent')
        .expect(404);
    });
  });
  
  // More tests...
});
            

This testing approach isolates the router, making tests more focused and less prone to interference from other parts of the application.

Best Practices for Router Modularization

graph LR A[Modular Express App] --> B[app.js] A --> C[routes/] A --> D[controllers/] A --> E[middleware/] A --> F[models/] C --> G[index.js] C --> H[user.routes.js] C --> I[product.routes.js] C --> J[order.routes.js] C --> K[auth.routes.js] D --> L[user.controller.js] D --> M[product.controller.js] D --> N[order.controller.js] D --> O[auth.controller.js] E --> P[auth.middleware.js] E --> Q[validation.middleware.js] E --> R[error.middleware.js]

Analogy: Following router modularization best practices is like urban planning for a growing city. You establish zones for different purposes (residential, commercial, industrial), create a logical street grid, standardize naming conventions for streets, and define clear boundaries. This makes the city navigable for newcomers, adaptable as it grows, and easier to maintain over time.

Practice Activities

Activity 1: Basic Router Modularization

Convert a monolithic Express application into a modular structure:

  • Create separate router files for different resources (users, products, auth)
  • Move route handlers to the appropriate router files
  • Create an index.js file to combine all routers
  • Mount the combined router in the main app.js file

Test the application to ensure all routes still work correctly.

Activity 2: Nested Resources API

Build a RESTful API with nested resources:

  • Create a users router with basic CRUD operations
  • Create a posts router that handles posts for a specific user
  • Create a comments router for comments on specific posts
  • Use mergeParams to access parent resource IDs
  • Implement proper parameter validation for all resources

Test the nested routes with tools like Postman or curl.

Activity 3: API Versioning with Router Factory

Implement an API with versioning and router factories:

  • Create a router factory that generates consistent CRUD routes
  • Implement v1 and v2 versions of your API with different response formats
  • Add new features in v2 that don't exist in v1
  • Create unit tests for both API versions
  • Document the differences between versions

Create a client that can work with either API version.

Key Takeaways

In the next lecture, we'll explore the Controller Pattern for separating route definitions from their implementation logic.