Routers and Controllers: A Clean Express Project Structure

MERN Stack Full Course: MongoDB, Express, React and Node.js · Node.js and Express · 6 min read · Updated

Router, controller, model

  1. As the API grows, one big file becomes hard to read. We split it by responsibility.
  2. index.js only mounts routers: /api/jobs goes to the jobs router.
  3. A router lists the URLs and the functions that answer them, and nothing else.
  4. Controllers hold the logic: read the input, do the work, send the reply.
  5. Controllers talk to the data layer. When we move to MongoDB, only this part changes.

One file with every route works for a demo, but a real API has dozens of routes, and a 600-line index.js is hard to read, test and change. The standard answer is to split by responsibility. A router lists the paths of one feature and says which function handles each. A controller holds the functions that do the work. The main file only assembles them.

In this lesson you build the whole jobs API, with five routes (list, read, create, change, delete), storing jobs in an array in memory. That is not a real database, since everything vanishes when the server restarts, but it lets us focus on structure and replace the storage in lesson 17 without touching the routes.

The router

import { Router } from 'express';
import { listJobs, getJob, createJob, updateJob, deleteJob } from '../controllers/jobs.js';

// A router groups the routes of one feature. index.js mounts it at /api/jobs.
const router = Router();

router.get('/', listJobs);
router.post('/', createJob);
router.get('/:id', getJob);
router.put('/:id', updateJob);
router.delete('/:id', deleteJob);

export default router;

Router() creates a mini app that can have routes and middleware of its own. Reading this file tells you the whole API of the feature at a glance: five lines, each with a method, a path and a handler. Notice that the paths here are relative: '/' and '/:id'. The main file decides the prefix.

The controller

// Controllers hold the logic of each route. For now the "database" is an array in memory,
// so the data vanishes when the server restarts. Lesson 17 replaces it with MongoDB.
let nextId = 3;
const jobs = [
  { id: 1, company: 'Northwind Bank', role: 'Teller', status: 'applied' },
  { id: 2, company: 'Acme Logistics', role: 'Dispatcher', status: 'interview' },
];

export const listJobs = (req, res) => res.json(jobs);

export const getJob = (req, res) => {
  const job = jobs.find((j) => j.id === Number(req.params.id));
  if (!job) return res.status(404).json({ message: 'Job not found' });
  res.json(job);
};

export const createJob = (req, res) => {
  const { company, role, status = 'saved' } = req.body;
  const job = { id: nextId++, company, role, status };
  jobs.push(job);
  res.status(201).json(job);
};

export const updateJob = (req, res) => {
  const job = jobs.find((j) => j.id === Number(req.params.id));
  if (!job) return res.status(404).json({ message: 'Job not found' });
  Object.assign(job, req.body);
  res.json(job);
};

export const deleteJob = (req, res) => {
  const index = jobs.findIndex((j) => j.id === Number(req.params.id));
  if (index === -1) return res.status(404).json({ message: 'Job not found' });
  jobs.splice(index, 1);
  res.status(204).end();
};

Each exported function is one route handler. They are plain functions of (req, res), so they are easy to read and, later, to test. For now they work on the jobs array. Look at how getJob, updateJob and deleteJob all start by finding the job and answering 404 when it does not exist. Repeating that pattern is a hint that it can be extracted into a helper, and in lesson 12 it will be.

Mounting it

// Lesson 11: routers and controllers.
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import { logger } from './middleware/logger.js';
import jobsRouter from './routes/jobs.js';

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

app.use(logger);
app.use(cors({ origin: process.env.CLIENT_ORIGIN || 'http://localhost:5173' }));
app.use(express.json({ limit: '100kb' }));

app.get('/api/health', (req, res) => res.json({ ok: true }));
app.use('/api/jobs', jobsRouter);

app.listen(port, () => console.log(`API listening on http://localhost:${port}`));

app.use('/api/jobs', jobsRouter) says: any request whose path starts with /api/jobs goes to this router, with that prefix removed. So GET /api/jobs/5 reaches router.get('/:id'). When you add users, files or statistics you add one line here and one new pair of files, and the rest of the app is untouched.

A rule of thumb for what goes where

  • Routes: paths, methods and which middleware runs. No logic.
  • Controllers: read the request, call the data layer, choose the status code and the answer.
  • Models (from lesson 16): how data looks and how it is stored.
  • Middleware and utils: things reused by many routes.

This is a convention, not magic. Smaller apps sometimes merge two layers, and larger ones add a “service” layer between controllers and models. What matters is that you can say, for any piece of code, which layer it belongs to and find it fast.

Your task

  1. Run the server and call GET /api/jobs, GET /api/jobs/1, and GET /api/jobs/99.
  2. Create a job with POST and a JSON body, then change its status with PUT, then delete it.
  3. Send a POST with an empty body {}. What was stored? Write down what you would want to happen instead.
  4. Add a third route file routes/health.js with its own router and mount it at /api/health.

How to check it worked

  • All five routes answer with the right status codes (200, 201, 204, 404).
  • An empty POST is accepted. You have seen the problem that lesson 12 fixes.

Summary

  • Routers list paths; controllers do the work; index.js assembles them.
  • app.use('/api/jobs', router) mounts a router under a prefix.
  • Keeping layers separate lets you change storage later without touching routes.

Download the solution (mern-lesson-11.zip)

Frequently asked questions

Do I have to use a controllers folder?

No, it is a convention. Small apps can keep handlers next to routes. The benefit of separating them is that the route file reads like a table of contents and the handlers are easy to find and test.

What does mounting a router do to the path?

app.use("/api/jobs", router) removes the prefix before the router sees the path. So a request for /api/jobs/5 matches router.get("/:id") with id 5.

Why store data in an array first?

It lets you learn routing and structure without a database. Because the routes and controller functions keep the same names, switching to MongoDB in lesson 17 changes only the bodies of the controller functions.