Home / Articles / Beyond CRUD: Ten Architecture Habits That Keep MERN Apps Maintainable

This article is published in English.

Beyond CRUD: Ten Architecture Habits That Keep MERN Apps Maintainable

Learn the system-level habits that keep a MERN app healthy as it grows: data ownership, API contracts, derived state, layered code, lean payloads and consistent errors.

1832 words

Most MERN tutorials end with a working CRUD app: MongoDB stores the data, Express exposes a few routes, React renders a list, and maybe there is a login screen. That app runs, but it rarely survives growth unchanged. As features and teammates accumulate, APIs become hard to change, state goes out of sync, pages slow down, and debugging eats more time than building. The tools are rarely the cause. This guide covers ten architectural habits that address those problems, so you can reason about a MERN application as one system rather than four libraries.

Treat MERN as a data flow, not a list of tools

The usual description of the stack is just its ingredients:

MongoDB + Express + React + Node.js

That is accurate but says nothing about how the parts cooperate, much like describing a car as an engine, four wheels and a steering wheel. A more useful picture follows the data from the user to the database and back:

User
   │
   ▼
React
   │
HTTP
   │
   ▼
Express + Node
   │
Database Queries
   │
   ▼
MongoDB

Each arrow is a boundary with its own rules: what the browser may send, what the server accepts, what the database stores. Most of the habits below are about deciding what happens at those boundaries.

1. Give every piece of data one owner

When building a feature, first ask who owns the data involved. In many young codebases the answer is unclear. The current user's details may live in component state, in a Redux store, in localStorage, in a fresh API response and in some other cache, all at once. Sooner or later one copy lags behind the others, and the UI shows two contradicting versions of the same fact.

A clearer division of responsibility:

  • MongoDB is the source of truth for persisted data.
  • The backend owns the business rules that decide how that data may change.
  • The frontend displays data and requests changes through the API; any client-side copy is a cache, not an authority.

The guiding principle is that each piece of data has exactly one source of truth, and every other copy knows it may be stale. Server-state libraries exist largely to manage that caching explicitly; see rethinking server state with React Query and Redux for that side of the problem.

2. Design APIs as contracts

A typical first endpoint looks like this:

app.get("/users", async (req, res) => {
  const users = await User.find();
  res.json(users);
});

It works, but it also quietly promises that the response will always be an array of full user documents, whatever fields the model happens to have. Once a mobile app, a dashboard, a partner integration or another team depends on that shape, changing it can break them.

Before adding an endpoint, decide:

  • exactly which fields it returns, rather than dumping the raw model (which may also leak internal or sensitive fields);
  • whether you can change it later without breaking clients, or whether it needs versioning;
  • which other systems are likely to consume it.

A carefully designed API can outlive several frontends; a careless one turns into technical debt within months.

3. Store as little React state as possible

React is introduced as a UI library, but in real applications most of the difficulty is state. A frequent mistake is keeping values in state that could be computed:

const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);

Here filteredUsers is entirely determined by users. Storing it separately means every update must keep both in sync, and forgetting once produces a stale list. Compute it during render instead:

const filteredUsers = users.filter(user => user.active);

The rule is to store only what you cannot calculate and derive everything else. If a derivation becomes genuinely expensive, useMemo can cache it, but it remains derived data rather than a second source of truth.

4. Remember that CRUD is the easy part

Plenty of projects stop at the four basic operations:

Create
Read
Update
Delete

A production backend wraps those operations in much more: validation, authentication, authorization, business rules, rate limiting, audit trails, logging and notifications. Compare a naive create:

await User.create(req.body);

with a version that checks the input first

if (!isValid(req.body))
    throw new Error("Invalid input");

and confirms the caller is allowed to act before writing:

if (!canCreateUser(req.user))
    throw new Error("Unauthorized");await User.create(req.body);

The naive version is also a mass-assignment risk: passing req.body straight to create lets a client set any field the schema accepts, including something like a role flag. Validate and pick the allowed fields explicitly. Note too that a failed permission check is conceptually a 403 (forbidden), not a 401, whatever the error message says. Writing data is simple; protecting it is where backend engineering gets hard.

5. Separate concerns into layers

The clearest difference between a hobby codebase and a professional one is where logic lives. Mixing concerns leads to route handlers full of database queries, React components full of validation rules and controllers packed with business logic, and each of those files keeps growing.

A layered backend assigns one job per layer:

Routes
   │
Controllers
   │
Services
   │
Repositories
   │
Database

Routes map URLs to handlers, controllers translate HTTP into function calls, services hold business rules, repositories talk to the database. Small, single-purpose units are easier to test and change. For a deeper walkthrough, see layered Node.js API design.

6. Fix performance at the data source first

Asked how to speed up a React app, most developers reach for useMemo, React.memo and useCallback. Those help, but many performance problems start before React gets involved. Picture a request like this:

GET /users

that returns

50,000 users

while the screen shows only

10 users

No amount of memoization compensates for shipping and parsing tens of thousands of unneeded records. Address it at the source:

  • paginate results;
  • filter on the server;
  • project only the fields the client needs;
  • compress responses;
  • cache deliberately, with a clear invalidation plan.

The quickest component to render is one that never receives data it does not need.

7. Make error handling part of the design

Development code often swallows errors like this:

try {
   ...
}
catch(error){
   console.log(error);
}

Logging and moving on hides the failure from the client and from monitoring. Production APIs need errors that are consistent and machine-readable:

return res.status(400).json({
    message: "Invalid email address",
    code: "INVALID_EMAIL"
});

A stable shape with a human-readable message and a machine-readable code means the frontend can map codes to specific UI messages, logs can be grouped by code, alerts can fire on unusual rates, and debugging starts from a known category instead of a stack trace. Failures are inevitable; handling them predictably is the goal.

8. Organize code by feature

With twenty files, any folder structure works. With five hundred, it matters a great deal. A layout grouped by technical type spreads one feature across the whole tree:

routes/
controllers/
models/

Grouping by feature keeps everything about one domain in one place:

users/
    routes.js
    controller.js
    service.js
    validation.js
orders/
    routes.js
    controller.js
    service.js

When the orders logic changes, you open the orders folder and nothing else. Feature folders also make ownership, code review and eventual extraction into separate services much easier.

9. Think in systems, not tickets

A feature request like "add login" can be answered narrowly, with a form and a route. A system-minded approach asks the surrounding questions: how authentication works end to end, where tokens are stored, how permissions are enforced, what happens when a token expires, and how a future mobile client will sign in. Answering those up front costs a little more today and avoids rewrites later.

10. Choose trade-offs deliberately

No architecture is best in every situation. Every option buys something and costs something:

  • Simple architecture: faster to build, harder to scale.
  • Microservices: independent scaling, much higher operational complexity.
  • Global state: easy sharing across components, harder debugging.
  • Normalized database design: less duplication, more joins or lookups.
  • Aggressive caching: faster responses, the ongoing problem of cache invalidation.

Strong engineers are not the ones who know every pattern, but the ones who can explain when each pattern is worth its cost.

How growing MERN projects typically decay

Stage 1: everything is simple

The first release covers the essentials:

CRUD
Authentication
Dashboard
Deployment

The code is small, and everyone understands all of it.

Stage 2: growth exposes shortcuts

More users, features and developers arrive. Duplicated logic, inconsistent endpoints, slow pages, tangled state and painful debugging appear all over the codebase.

Stage 3: the tools get blamed

The team concludes that React does not scale or that choosing MongoDB was a mistake. Usually neither is true. The architecture simply never evolved alongside the application.

A restaurant analogy for the layers

Think of the stack as a restaurant. MongoDB is the pantry that stores every ingredient. Express and Node are the kitchen: they decide what gets cooked, how it is prepared and who is allowed to order. React is the front of house, presenting finished dishes to guests. Guests do not need to know how the kitchen works, and the kitchen does not care how plates are arranged on the table. Each part does one job well, which is exactly the separation a MERN app needs.

Key takeaways

Knowing MERN is less about writing queries, routes and components, and more about seeing the paths data takes, placing business rules in the right layer, evolving APIs without breaking clients, keeping state minimal and recognizing how early decisions compound.

  • Give each piece of data one owner and treat every other copy as a cache.
  • Treat endpoints as contracts and return explicit, deliberate shapes.
  • Derive state instead of duplicating it.
  • Validate, authorize and whitelist fields before writing anything.
  • Keep payloads small at the source before optimizing rendering.
  • Return consistent, coded errors and group code by feature.

When a new feature comes up, the most useful question is not how to build it but where each responsibility belongs.