MERN Stack Architecture: Patterns That Scale
How I structure Express routes, Mongoose schemas and the React data layer so a MERN project is still maintainable at month six — with the code I actually use.
Founder & lead developer at WebDevStudio — React, TypeScript and MERN
MERN projects rarely fail because of the stack. They fail because everything ends up in the route handler — validation, business logic, database queries and response shaping — and six months later nobody can change anything without breaking something else. These are the patterns I use to keep that from happening.
Separate routes, controllers and services
Three layers, each with one job. The route declares the URL and its middleware. The controller translates between HTTP and your domain. The service holds the actual logic and knows nothing about requests or responses — which is what makes it testable without spinning up a server.
// routes/orders.js — URLs and middleware only
router.post("/orders", requireAuth, validate(createOrderSchema), createOrder);
// controllers/orders.js — HTTP in, HTTP out
export async function createOrder(req, res, next) {
try {
const order = await orderService.create(req.user.id, req.body);
res.status(201).json(order);
} catch (err) {
next(err);
}
}
// services/orders.js — pure logic, no req/res anywhere
export async function create(userId, input) {
const total = calculateTotal(input.items);
if (total <= 0) throw new ValidationError("Order total must be positive");
return Order.create({ userId, ...input, total });
}The test for whether you have this right: can you call the service from a script, a cron job or a queue worker without faking a request object? If not, the logic is still in the wrong layer.
Design schemas around your queries
This is the biggest mental shift coming from SQL. In MongoDB you model for how data is read, not for normalised purity. If you always load an order together with its line items, embed them. If line items are queried independently or grow without bound, reference them.
- Embed when the child is always read with the parent and the array stays bounded
- Reference when the child is queried on its own, shared between parents, or unbounded
- Index every field you filter, sort or join on — a compound index must match your query's field order
- Never let an embedded array grow without a limit; documents cap at 16MB
const orderSchema = new Schema(
{
userId: { type: Schema.Types.ObjectId, ref: "User", required: true },
status: { type: String, enum: ["pending", "paid", "shipped"], default: "pending" },
items: [{ sku: String, qty: Number, price: Number }], // bounded, always read together
total: { type: Number, required: true, min: 0 },
},
{ timestamps: true }
);
// Matches: find({ userId, status }).sort({ createdAt: -1 })
orderSchema.index({ userId: 1, status: 1, createdAt: -1 });Validate at the boundary, once
Every request body is untrusted. Validate it the moment it arrives, with a schema, and let everything downstream assume the shape is correct. Zod works well here because the same schema can be reused on the React side, so the client and server cannot drift apart.
import { z } from "zod";
export const createOrderSchema = z.object({
items: z.array(
z.object({ sku: z.string().min(1), qty: z.number().int().positive() })
).min(1),
note: z.string().max(500).optional(),
});
export const validate = (schema) => (req, res, next) => {
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "Invalid request", issues: parsed.error.issues });
}
req.body = parsed.data; // now guaranteed to match the schema
next();
};Plan the auth refresh flow before you ship
Retrofitting refresh tokens onto a live app is painful, because every client already holds credentials in the old format. Decide up front: a short-lived access token, a longer-lived refresh token in an httpOnly cookie, and a single endpoint that exchanges one for the other. Storing access tokens in localStorage is convenient and leaves them readable by any injected script.
One error shape, one handler
Define your error classes once and let a single Express error middleware turn them into responses. Without this you end up with four different error formats and a frontend full of special cases.
export class AppError extends Error {
constructor(message, status = 500, code = "internal_error") {
super(message);
this.status = status;
this.code = code;
}
}
// Last middleware registered — everything funnels through here
app.use((err, req, res, _next) => {
const status = err.status ?? 500;
if (status >= 500) console.error(err);
res.status(status).json({
error: err.code ?? "internal_error",
message: status >= 500 ? "Something went wrong" : err.message,
});
});Keep fetch logic out of components
On the React side, put every call behind a query hook. Components then describe what they need rather than how to get it, and you get caching, deduplication and background refresh without writing any of it yourself.
export function useOrders(status: OrderStatus) {
return useQuery({
queryKey: ["orders", status],
queryFn: () => api.get("/orders", { params: { status } }),
staleTime: 30_000,
});
}
// In the component
const { data: orders, isPending, error } = useOrders("pending");Deploy the two halves independently
A frontend on a CDN and an API on its own host can be released and rolled back separately. Coupling them means a CSS fix requires redeploying your database connections. Keep configuration in environment variables on both sides, and never commit a secret — a client-side variable is inlined into the bundle and is public by definition.
None of this is exotic. It is mostly about deciding where things live before the deadline pressure arrives, because that is the moment everything ends up in the route handler.
Two companion pieces: the TypeScript patterns that keep the boundaries above actually enforced rather than merely documented, and the React performance work that starts to matter once the data layer is doing its job and the remaining cost is in the browser.
Interested in working together on a React or MERN project?
Get in Touch