What you'll learn
By the end of this tutorial you'll know how to structure an Express API for a real MERN project — not just routes that work, but routes that are easy to navigate, maintain, and extend as the project grows. You'll understand how to organize routes, controllers, and middleware into clear layers, how to use HTTP status codes correctly, how to validate incoming data on the server, and how to return consistent error shapes that React can handle reliably.
Good API design makes React development faster too: when response shapes are consistent and status codes are meaningful, frontend error handling becomes a small, predictable task instead of a case-by-case maze.
Who this is for
- MERN developers whose Express code is functional but feels like it's growing in the wrong direction
- Developers who've written flat route files and now need to scale them without starting over
- Anyone building a REST API for the first time who wants opinionated patterns, not just syntax
You can skip this if you're building with a structured framework like NestJS, which enforces these patterns for you with decorators and modules. This tutorial is for vanilla Express, where the structure is entirely yours to define.
What is API design in a MERN context?
It's the set of decisions that determine how your React frontend communicates with your Express backend — what URLs exist, what verbs they accept, what they return on success, and what they return when something goes wrong.
Plain English: API design is writing the contract between React and Express. Design it well and adding a feature means adding a file. Design it poorly and adding a feature means carefully editing five files while trying not to break the other twelve.
Simple idea: think of your API as a menu. Good menus have predictable names, clear sections, and consistent formatting. Good APIs work the same way — any developer on the team should be able to predict a URL and a response shape before looking at the code.
Prerequisites
- A working Express server with a MongoDB connection (see MERN stack architecture)
- At least one Mongoose model in the project
- Basic understanding of HTTP verbs: GET, POST, PUT, DELETE
Setup from zero
Step 1 — Adopt the layered folder structure
Replace flat route files with a clear three-layer structure:
server/
routes/ ← URL matching only — no logic
controllers/ ← business logic — database calls live here
models/ ← Mongoose schemas
middleware/ ← auth, validation, error handling
utils/ ← shared helpers, e.g. error formatting
This structure makes one question trivially easy to answer: "where does the code that handles this request live?" The answer is always: in the controller file named after the resource.
Step 2 — Write a thin route file
// server/routes/posts.js
const express = require("express");
const router = express.Router();
const posts = require("../controllers/postsController");
const protect = require("../middleware/auth");
router.get("/", posts.list);
router.get("/:id", posts.get);
router.post("/", protect, posts.create);
router.put("/:id", protect, posts.update);
router.delete("/:id", protect, posts.remove);
module.exports = router;
Notice what this file does not contain: database queries, validation logic, try/catch blocks. A route file is a mapping of URL patterns to functions — nothing more.
Step 3 — Write the controller
// server/controllers/postsController.js
const Post = require("../models/Post");
exports.list = async (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(50, parseInt(req.query.limit) || 20);
const skip = (page - 1) * limit;
const [posts, total] = await Promise.all([
Post.find({ status: "published" }).skip(skip).limit(limit).sort({ createdAt: -1 }),
Post.countDocuments({ status: "published" }),
]);
res.json({ posts, total, page, pages: Math.ceil(total / limit) });
} catch (err) {
res.status(500).json({ error: "Failed to fetch posts" });
}
};
exports.get = async (req, res) => {
try {
const post = await Post.findById(req.params.id);
if (!post) return res.status(404).json({ error: "Post not found" });
res.json(post);
} catch (err) {
res.status(500).json({ error: "Failed to fetch post" });
}
};
exports.create = async (req, res) => {
try {
const { title, body } = req.body;
if (!title || !body) {
return res.status(400).json({ error: "title and body are required" });
}
const post = await Post.create({ title, body, authorId: req.userId });
res.status(201).json(post);
} catch (err) {
res.status(500).json({ error: "Failed to create post" });
}
};
exports.update = async (req, res) => {
try {
const post = await Post.findOneAndUpdate(
{ _id: req.params.id, authorId: req.userId },
req.body,
{ new: true, runValidators: true }
);
if (!post) return res.status(404).json({ error: "Post not found" });
res.json(post);
} catch (err) {
res.status(500).json({ error: "Failed to update post" });
}
};
exports.remove = async (req, res) => {
try {
await Post.findOneAndDelete({ _id: req.params.id, authorId: req.userId });
res.status(204).send();
} catch (err) {
res.status(500).json({ error: "Failed to delete post" });
}
};
> Little tip: In findOneAndUpdate and findOneAndDelete, include authorId: req.userId in the filter alongside the document ID. This ensures users can only modify their own documents — a filter with just _id lets any authenticated user edit anyone's data, which is a silent authorization bug.
The mental model
Think of your API as a set of resources, not actions. A resource is a thing — Post, User, Comment, Order. Routes are operations on those resources using the HTTP verbs that already exist for exactly this purpose.
Action-based (wrong direction):
- POST /createPost
- GET /getAllPosts
- POST /deletePost
Resource-based (right direction):
- POST /api/posts — creates a post
- GET /api/posts — lists posts
- DELETE /api/posts/:id — deletes a post
HTTP already has the verbs. The noun in the URL (/posts) describes the thing being operated on. The verb (POST, GET, DELETE) describes the operation. Use both together and your API becomes self-documenting.
Key terms
REST (Representational State Transfer) — an architectural style for APIs that uses HTTP verbs, resource-based URLs, and stateless requests. Most Express APIs follow REST conventions, even loosely.
Idempotent — an operation that produces the same result no matter how many times you call it. GET is idempotent. PUT should be. POST typically isn't — calling it twice creates two records.
Status code — the three-digit number in every HTTP response. 200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 500 Internal Server Error. Use them correctly and React can check res.ok instead of parsing every response body.
Request body — data sent by the client in POST or PUT requests. Parsed by express.json() middleware into req.body.
Query parameters — key-value pairs after ? in the URL. Used for filtering, pagination, and sorting. Example: GET /api/posts?page=2&limit=10&status=published.
Controller — a module of functions, one per operation (list, get, create, update, remove). The only place in the server where database queries and business logic live.
Patterns
Consistent error shape
Always return errors in the same structure so React can handle them generically:
// utils/apiError.js
exports.apiError = (res, status, message) =>
res.status(status).json({ error: message });
React checks data.error on every failed request — no guessing the shape:
const res = await fetch("/api/posts", options);
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Something went wrong");
Input validation with express-validator
npm install express-validator
// middleware/validatePost.js
const { body, validationResult } = require("express-validator");
const rules = [
body("title").trim().notEmpty().withMessage("Title is required"),
body("body").trim().isLength({ min: 10 }).withMessage("Body must be at least 10 characters"),
];
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
};
module.exports = [...rules, validate];
Apply in the route:
const validatePost = require("../middleware/validatePost");
router.post("/", protect, validatePost, posts.create);
> Little tip: Validate on the server even when you also validate in React. React validation is for user experience — fast feedback without a network round-trip. Server validation is for security — it's the only layer that can't be bypassed by disabling JavaScript or crafting a raw HTTP request.
Common mistakes
Using 200 for every response — returning { success: false, message: "Not found" } with a 200 status code is confusing. Use 400, 401, 404, 500 as appropriate. React can then rely on response.ok (true for 200–299) as a single reliable success check.
Putting business logic in route files — a route file that grows to 200+ lines is a sign that controllers are missing. Move database queries and any logic more complex than a field read out of route files immediately.
Forgetting return before error responses — every early res.status(...).json(...) needs return before it. Without it, Express continues executing the function and tries to send a second response, throwing "Cannot set headers after they are sent" — one of Express's most common runtime errors.
Troubleshooting
"Cannot set headers after they are sent" — a response is being sent twice from the same handler. Search the controller for res.json calls without return before them. Every early return path needs to actually return.
req.body is undefined — express.json() middleware isn't applied. Make sure app.use(express.json()) appears before any route registration in index.js.
MongoDB CastError on findById — the ID in the URL isn't a valid MongoDB ObjectId. Add a guard before the query:
if (!mongoose.isValidObjectId(req.params.id)) {
return res.status(400).json({ error: "Invalid ID format" });
}
Checklist
- [ ]
routes/contains only URL-to-function mappings, no logic - [ ]
controllers/contains all database queries and business logic - [ ] All routes use correct HTTP verbs — GET for reads, POST for creates, PUT for updates, DELETE for deletes
- [ ] Error responses use meaningful status codes — not always 200
- [ ]
returnappears before every early error response - [ ] Input validation middleware runs before controllers on POST and PUT routes
- [ ] List endpoints support
?pageand?limitpagination - [ ] Error shapes are consistent across all endpoints
Practice task
Refactor the notes app from the MERN stack architecture tutorial. Move all logic out of route files and into a notesController.js. Add pagination to the list endpoint (?page and ?limit query params). Add input validation to the create and update endpoints. Add a shared apiError utility and use it in every error response. Confirm the response shapes from the browser's Network tab look identical across all endpoints.
FAQ
Should all routes be prefixed with /api?
Yes, if you're serving your React app from the same Express server in production. The /api prefix lets a reverse proxy or hosting platform cleanly route API requests to Express and everything else to the React static build.
Do I need API versioning — /api/v1?
For a solo project or internal app, /api is fine. Add versioning when you have external consumers — mobile apps, third-party integrations — that can't all update simultaneously. Versioning lets you introduce breaking changes in /api/v2 without breaking /api/v1 clients.
Should I use GraphQL instead of REST?
REST is the right starting point for MERN. GraphQL solves real problems — over-fetching, multiple clients with different data needs, a large interconnected data graph — but it adds significant complexity: schema definition, resolver architecture, the N+1 query problem. Start with REST and switch when you have a specific, concrete reason.
What to learn next
Once the route/controller pattern is solid, the natural next steps are: authentication middleware to protect these routes (see MERN auth basics), deploying the Express backend (see MERN deployment checklist), and MongoDB aggregation pipelines for complex multi-step queries.
Related on Baseline
- MERN auth basics — adding JWT protection to the routes designed here
- MERN stack architecture — the foundational layer this API design builds on
- MongoDB modeling for MERN — designing the Mongoose models your controllers query
Takeaways
Good Express API design rests on three separations: routes handle URL matching, controllers handle logic, models handle data shape. Use HTTP verbs correctly. Return meaningful status codes. Validate input on the server. Make error shapes consistent. These four practices together make a MERN API that React developers can work with confidently.
If you remember only one thing: routes and controllers are not the same thing. A route file matches a URL and calls a function. A controller file contains that function — and all the logic inside it. As soon as logic creeps into route files, the codebase becomes difficult to search, test, or reason about. Keep them separate from day one.