Express Router and Modular Route Files

When your Express API has five or ten routes, keeping them all in index.js is manageable. When it has fifty โ€” covering posts, users, auth, comments, and file uploads โ€” a single file becomes a maintenance nightmare. express.Router() is the solution: it lets you create mini Express applications that handle a subset of your routes, which you then mount on a path prefix in your main app. The result is a clean, modular project where each resource has its own dedicated route file, its own controller, and its own logical boundary. In this lesson you will refactor a single-file server into a properly structured Express application.

What express.Router() Provides

Feature Detail
Isolated route scope Routes defined on a Router only match within its mounted path
Own middleware stack Middleware added to a Router only applies to that Router’s routes
Mountable at any prefix app.use('/api/posts', postsRouter) โ€” all router routes become /api/posts/...
Composable Routers can be nested โ€” a router can mount another router
Same API as app All app.get/post/put/delete/use methods work on Router too
Note: When you mount a Router at a path prefix with app.use('/api/posts', postsRouter), the prefix is stripped before matching inside the Router. So router.get('/', handler) inside the Router matches GET /api/posts, and router.get('/:id', handler) matches GET /api/posts/:id. You do not repeat /api/posts inside the Router file.
Tip: Keep your route files thin โ€” they should only define which handler function runs for each route. Put the actual logic (database queries, data transformation) in a separate controller file. This separation makes your handlers easy to test in isolation and makes it obvious what each route does just by reading the route file.
Warning: Router-level middleware only applies to routes defined on that Router. If you need middleware to apply to every route in your entire API (like CORS or request logging), add it to the main app with app.use() before mounting any routers. Middleware added to a Router after the Router is mounted on app will not run for requests that matched an earlier Router.

Before โ€” Everything in index.js

// server/index.js โ€” hard to maintain at scale
require('dotenv').config();
const express = require('express');
const app     = express();

app.use(express.json());

// Posts routes (mixed in with everything else)
app.get('/api/posts',    async (req, res) => { /* ... */ });
app.post('/api/posts',   async (req, res) => { /* ... */ });
app.get('/api/posts/:id', async (req, res) => { /* ... */ });
app.put('/api/posts/:id', async (req, res) => { /* ... */ });
app.delete('/api/posts/:id', async (req, res) => { /* ... */ });

// Auth routes
app.post('/api/auth/register', async (req, res) => { /* ... */ });
app.post('/api/auth/login',    async (req, res) => { /* ... */ });

// User routes
app.get('/api/users/:id',    async (req, res) => { /* ... */ });
app.patch('/api/users/:id',  async (req, res) => { /* ... */ });

app.listen(5000);

After โ€” Modular Router Files

// โ”€โ”€ server/src/routes/posts.js โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const express    = require('express');
const router     = express.Router();
const {
  getAllPosts,
  getPostById,
  createPost,
  updatePost,
  deletePost,
} = require('../controllers/postController');
const protect = require('../middleware/auth');

// GET /api/posts  and  POST /api/posts
router.route('/')
  .get(getAllPosts)
  .post(protect, createPost);

// GET /api/posts/:id  PUT /api/posts/:id  DELETE /api/posts/:id
router.route('/:id')
  .get(getPostById)
  .put(protect, updatePost)
  .delete(protect, deletePost);

module.exports = router;
// โ”€โ”€ server/src/routes/auth.js โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const express = require('express');
const router  = express.Router();
const { register, login, getMe } = require('../controllers/authController');
const protect = require('../middleware/auth');

router.post('/register', register);
router.post('/login',    login);
router.get('/me',        protect, getMe);

module.exports = router;
// โ”€โ”€ server/src/routes/users.js โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const express = require('express');
const router  = express.Router();
const { getUserProfile, updateProfile } = require('../controllers/userController');
const protect = require('../middleware/auth');

router.get('/:id',   getUserProfile);
router.patch('/:id', protect, updateProfile);

module.exports = router;
// โ”€โ”€ server/index.js โ€” clean and minimal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
require('dotenv').config();
const express   = require('express');
const cors      = require('cors');
const helmet    = require('helmet');
const connectDB = require('./src/config/db');

const app  = express();
const PORT = process.env.PORT || 5000;

connectDB();

// โ”€โ”€ Global middleware โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(helmet());
app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' }));
app.use(express.json());

// โ”€โ”€ Mount routers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use('/api/posts', require('./src/routes/posts'));
app.use('/api/auth',  require('./src/routes/auth'));
app.use('/api/users', require('./src/routes/users'));

// โ”€โ”€ 404 and error handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(require('./src/middleware/notFound'));
app.use(require('./src/middleware/errorHandler'));

app.listen(PORT, () => console.log(`Server โ†’ http://localhost:${PORT}`));

Controller Pattern โ€” Separating Logic from Routes

// โ”€โ”€ server/src/controllers/postController.js โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const Post = require('../models/Post');

// @desc    Get all posts
// @route   GET /api/posts
// @access  Public
const getAllPosts = async (req, res, next) => {
  try {
    const { page = 1, limit = 10, tag, published } = req.query;
    const filter = {};
    if (tag)       filter.tags      = tag;
    if (published) filter.published = published === 'true';

    const posts = await Post.find(filter)
      .sort({ createdAt: -1 })
      .skip((page - 1) * limit)
      .limit(parseInt(limit, 10));

    res.json({ success: true, count: posts.length, data: posts });
  } catch (err) {
    next(err);
  }
};

// @desc    Get single post
// @route   GET /api/posts/:id
// @access  Public
const getPostById = async (req, res, next) => {
  try {
    const post = await Post.findById(req.params.id).populate('author', 'name avatar');
    if (!post) return res.status(404).json({ success: false, message: 'Post not found' });
    res.json({ success: true, data: post });
  } catch (err) {
    next(err);
  }
};

module.exports = { getAllPosts, getPostById }; // export all handlers

Final Project Structure

server/
โ”œโ”€โ”€ index.js                  โ† entry point โ€” mounts routers, starts server
โ”œโ”€โ”€ .env
โ”œโ”€โ”€ nodemon.json
โ””โ”€โ”€ src/
    โ”œโ”€โ”€ config/
    โ”‚   โ””โ”€โ”€ db.js             โ† Mongoose connection
    โ”œโ”€โ”€ models/
    โ”‚   โ”œโ”€โ”€ Post.js
    โ”‚   โ””โ”€โ”€ User.js
    โ”œโ”€โ”€ controllers/
    โ”‚   โ”œโ”€โ”€ postController.js โ† getAllPosts, getPostById, createPost...
    โ”‚   โ”œโ”€โ”€ authController.js โ† register, login, getMe
    โ”‚   โ””โ”€โ”€ userController.js โ† getUserProfile, updateProfile
    โ”œโ”€โ”€ routes/
    โ”‚   โ”œโ”€โ”€ posts.js          โ† router.get('/', ...) router.get('/:id', ...)
    โ”‚   โ”œโ”€โ”€ auth.js
    โ”‚   โ””โ”€โ”€ users.js
    โ””โ”€โ”€ middleware/
        โ”œโ”€โ”€ auth.js           โ† JWT protect middleware
        โ”œโ”€โ”€ notFound.js       โ† 404 handler
        โ””โ”€โ”€ errorHandler.js   โ† global error middleware

Common Mistakes

Mistake 1 โ€” Repeating the mount path inside the Router

โŒ Wrong โ€” the prefix is already added by app.use():

// routes/posts.js
router.get('/api/posts',     getAllPosts);  // results in /api/posts/api/posts
router.get('/api/posts/:id', getPostById); // results in /api/posts/api/posts/:id

โœ… Correct โ€” Router paths are relative to the mount point:

router.get('/',    getAllPosts);  // matches GET /api/posts โœ“
router.get('/:id', getPostById); // matches GET /api/posts/:id โœ“

Mistake 2 โ€” Putting business logic directly in route files

โŒ Wrong โ€” database queries inside the route file blur responsibilities:

// routes/posts.js
router.get('/', async (req, res) => {
  const posts = await Post.find().sort({ createdAt: -1 }).limit(10);
  res.json(posts);
}); // route file now has business logic โ€” hard to test, hard to reuse

โœ… Correct โ€” keep route files as thin wiring; put logic in controller files.

Mistake 3 โ€” Forgetting to export the router

โŒ Wrong โ€” router is never exported so index.js gets undefined:

// routes/posts.js
const router = express.Router();
router.get('/', handler);
// forgot: module.exports = router;

// index.js
app.use('/api/posts', require('./src/routes/posts')); // undefined โ†’ crash

โœ… Correct โ€” always export the router at the bottom of every route file:

module.exports = router; // โœ“

Quick Reference

Task Code
Create a router const router = express.Router()
Define route on router router.get('/', handler)
Chain methods on router router.route('/').get(h1).post(h2)
Export router module.exports = router
Mount router in app app.use('/api/posts', require('./routes/posts'))
Router-level middleware router.use(protect)

🧠 Test Yourself

In your route file you define router.get('/:id', getPostById) and mount it with app.use('/api/posts', postsRouter). What URL path does this route handler respond to?