Introduction
Server-side rendering is older than most modern web developers' careers. Long before React, Next.js, or the SPA revolution, web applications rendered HTML on the server and sent complete documents to the browser. That era produced architectural thinking that remains deeply relevant today - most notably the Model-View-Controller pattern.
MVC gave developers a vocabulary and a structure for separating concerns in applications where the server owned both the logic and the presentation. As SPAs rose to dominance in the 2010s, MVC in the traditional server-rendered sense faded from the spotlight. But SSR has made a strong comeback - driven by performance demands, SEO requirements, and the growing complexity of client-side JavaScript. With it, MVC has re-emerged as a foundational blueprint, often misunderstood, frequently misapplied, and rarely examined with the depth it deserves.
This article is a thorough examination of MVC in the context of server-side rendering: what the pattern actually means, how it maps to modern SSR pipelines, where it excels, where it creates friction, and how experienced engineers can apply it effectively.
What MVC Actually Means - and What It Doesn't
MVC was originally described by Trygve Reenskaug in the late 1970s during his work on Smalltalk at Xerox PARC. The pattern was designed for GUI applications, not the web. Its migration to web development - first through frameworks like Struts, then Rails, Django, and Laravel - adapted the original ideas but also introduced ambiguities that persist today.
In the strictest definition, the Model represents the application's data and business rules. It is completely independent of how data is displayed or how user input arrives. The View is the presentation layer - it renders data from the Model for the user. The Controller receives user input, invokes the appropriate Model logic, and selects the correct View to render. The key insight is directional: the Controller responds to requests, the Model processes them, and the View renders results. None of these components should bleed into each other's domain.
In web SSR frameworks, this mapping becomes concrete: an HTTP request arrives, is routed to a Controller action, which calls Model methods (querying a database, applying business rules), and then passes the resulting data to a View template (an EJS file, a Jinja2 template, a Handlebars layout) that produces HTML. The server sends that HTML to the client. This is a clean, stateless, and well-understood cycle.
What MVC does not mean is a flat folder structure with three directories named models, views, and controllers. That's a file organization strategy, not an architecture. Conflating the two is one of the most common sources of confusion - and one of the earliest signs that a codebase is accumulating the wrong kind of technical debt.

SSR in the Modern Context
Server-side rendering today is not the same as it was in 2005. The term now encompasses a spectrum: traditional full-page SSR (as in Rails or Django), hybrid SSR with partial hydration (as in Next.js or Nuxt), streaming SSR (React 18's renderToPipeableStream), and edge SSR where rendering happens close to the user. Each variant changes how MVC maps onto the rendering pipeline.
In traditional full-page SSR, the MVC cycle is complete and discrete. Each page request triggers the full cycle: route -> controller -> model -> view -> response. There is no client-side state to manage between renders. The server owns the entire rendering lifecycle, making it straightforward to reason about and test in isolation.
In hybrid frameworks like Next.js, the picture is more complex. getServerSideProps or React Server Components play the role of the Controller-Model boundary - they fetch data and pass it as props to components that serve as Views. But the component tree also runs on the client for hydration, meaning the same "View" code executes in two environments. This dual execution is a significant departure from classical MVC and demands careful thinking about where business logic lives and how state flows.
Understanding where you are on this spectrum matters before applying MVC. The pattern was designed for discrete, stateless request-response cycles. The closer your SSR approach is to that model, the more cleanly MVC applies. As you move toward hybrid and streaming SSR, the pattern remains useful as a mental model but requires deliberate adaptation to remain structurally sound.
Deep Technical Explanation: MVC Components in an SSR Pipeline
The Model Layer
The Model is responsible for everything related to data: how it is fetched, validated, transformed, and persisted. In an SSR context, this typically includes database query logic, external API calls, domain entities, and business rules. The Model layer should be completely agnostic of HTTP - it should not know or care that a web request triggered its execution.
A well-designed Model layer is the most reusable part of the application. The same UserRepository, OrderService, or ProductCatalog class should be callable from a web controller, a CLI tool, a background job, or a test suite without modification. When models start importing request objects, reading cookies, or referencing session data, they have been contaminated by the transport layer - and the architectural boundary has been broken.
In practice, this means the Model layer should expose domain-level interfaces. A getUserById(id: string): Promise<User> method is a Model method. A getUserFromRequest(req: Request): Promise<User> method is a Controller-level concern that calls the Model. This distinction sounds trivial but has profound consequences at scale.
The View Layer
In SSR, the View layer produces HTML. In traditional frameworks this is a template engine - Jinja2 in Django, ERB or Haml in Rails, Handlebars in Express applications, Twig in Symfony. In modern Node.js SSR the View might be a React component tree rendered to a string using renderToString or renderToPipeableStream.
The View should receive fully-resolved data - not queries, not promises, not raw database records. It should be a pure transformation: given this data, produce this markup. Views that contain business logic (conditional access checks, data transformations, complex formatting) become fragile and difficult to test. The goal is that a designer or front-end specialist with no knowledge of the business domain could open a template and understand what it does.
Partials, layouts, and component composition are all legitimate View-layer concerns. The principle of DRY applies here: shared navigation, footers, form elements, and error states should be extracted into reusable View components. This is not a violation of MVC - it is the natural expression of composition within a single layer.
The Controller Layer
The Controller is the orchestrator. It does not contain business logic - that belongs in the Model. It does not produce HTML - that belongs in the View. It does three things: it interprets the incoming request (extracting route parameters, query strings, body data), it invokes Model methods with the appropriate arguments, and it selects and renders the correct View with the resulting data.
A well-written Controller action is typically short - ten to thirty lines in most real-world applications. If a controller action exceeds that, it is usually a sign that business logic has leaked into the wrong layer. This is the "fat controller" antipattern, and it is endemic in applications where MVC was applied as a folder structure rather than an architectural discipline.
Controllers also handle the HTTP-level concerns that neither Models nor Views should touch: setting response status codes, managing redirects, writing cookies, and handling authentication middleware. These are genuinely controller responsibilities because they are part of the HTTP response contract, not the business logic or the presentation.

Implementation: A Realistic Node.js + TypeScript SSR Example
The following example uses Express with a template engine (EJS) to illustrate clean MVC separation. It is intentionally simplified but reflects patterns found in production-grade SSR applications.
// Model: pure domain logic, no HTTP awareness
import { db } from "../db/connection";
export interface Product {
id: string;
name: string;
price: number;
stock: number;
}
export class ProductRepository {
async findById(id: string): Promise<Product | null> {
return db.query<Product>("SELECT * FROM products WHERE id = $1", [id]);
}
async findAll(page: number, limit: number): Promise<Product[]> {
const offset = (page - 1) * limit;
return db.queryMany<Product>(
"SELECT * FROM products ORDER BY name LIMIT $1 OFFSET $2",
[limit, offset],
);
}
async findByPriceRange(min: number, max: number): Promise<Product[]> {
return db.queryMany<Product>(
"SELECT * FROM products WHERE price BETWEEN $1 AND $2",
[min, max],
);
}
}
// Controller: orchestrates Model + View, handles HTTP concerns
import { Request, Response, NextFunction } from "express";
import { ProductRepository } from "../models/product.model";
const productRepo = new ProductRepository();
export async function listProducts(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = 20;
const products = await productRepo.findAll(page, limit);
res.render("products/index", {
products,
currentPage: page,
title: "Product Catalog",
});
} catch (err) {
next(err);
}
}
export async function showProduct(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const product = await productRepo.findById(req.params.id);
if (!product) {
res.status(404).render("errors/404", { title: "Product Not Found" });
return;
}
res.render("products/show", { product, title: product.name });
} catch (err) {
next(err);
}
}
// Router: maps HTTP verbs + paths to controller actions
import { Router } from "express";
import { listProducts, showProduct } from "../controllers/product.controller";
const router = Router();
router.get("/products", listProducts);
router.get("/products/:id", showProduct);
export default router;
<%# View: pure presentation, receives fully-resolved data %> <%-
include('../partials/header', { title }) %>
<main class="product-detail">
<h1><%= product.name %></h1>
<p class="price">$<%= product.price.toFixed(2) %></p>
<% if (product.stock > 0) { %>
<span class="badge badge--available"
>In Stock (<%= product.stock %> remaining)</span
>
<button class="btn btn--primary" data-product-id="<%= product.id %>">
Add to Cart
</button>
<% } else { %>
<span class="badge badge--unavailable">Out of Stock</span>
<% } %>
</main>
<%- include('../partials/footer') %>
This example demonstrates the key discipline: the Model knows nothing about Express, the Controller knows nothing about SQL, and the View knows nothing about either. Each layer can be tested independently - the Model with a test database, the Controller with mocked repositories, the View with fixture data.
MVC with Modern Frameworks: Next.js and Beyond
Applying MVC thinking to Next.js or similar hybrid frameworks requires a conceptual translation. The framework does not give you a Controller class - but the pattern can still be applied deliberately.
In the Pages Router, getServerSideProps is effectively a Controller action. It receives the request context, calls data-fetching logic (your Model layer), and returns props (the data contract for the View). The page component is the View. Applying MVC discipline here means keeping getServerSideProps thin: it should call service or repository functions, not contain inline SQL or complex business logic.
// Next.js Pages Router - MVC applied deliberately
import type { GetServerSideProps, NextPage } from "next";
import { ProductRepository } from "../../src/models/product.model";
import { ProductDetail } from "../../src/components/ProductDetail";
import type { Product } from "../../src/models/product.model";
interface Props {
product: Product;
}
// This is the "Controller" - thin orchestration only
export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
const repo = new ProductRepository();
const product = await repo.findById(ctx.params?.id as string);
if (!product) {
return { notFound: true };
}
return { props: { product } };
};
// This is the "View" - pure presentation
const ProductPage: NextPage<Props> = ({ product }) => (
<ProductDetail product={product} />
);
export default ProductPage;
In the App Router with React Server Components, the boundary shifts again. A Server Component can directly call data-fetching logic, acting as a tightly coupled Controller-View. This collapses two MVC layers into one construct, which increases locality but requires stronger discipline to avoid embedding business logic in component files. The pattern still applies - but it must be enforced through code organization conventions rather than framework structure.
Trade-offs and Pitfalls
The Fat Model Problem
MVC's prescription to keep controllers thin often results in the opposite problem: fat models. When developers correctly remove business logic from controllers, they sometimes consolidate everything into model classes until those classes become enormous, multi-responsibility blobs. A UserModel that handles authentication, profile management, notification preferences, billing, and audit logging is not a well-designed model - it is a violation of the Single Responsibility Principle wearing MVC clothing.
The solution is not to abandon MVC but to recognize that large applications need a richer domain layer beneath it. Service objects, domain entities, value objects, and repositories are not alternatives to MVC - they are refinements within the Model layer. A mature application's architecture will show MVC at the HTTP boundary and a richer object model within.
View Logic Creep
The second most common pitfall is allowing business logic to accumulate in templates. This often starts innocuously: a conditional to show a "Premium" badge, a loop to filter out inactive items, a check to display an admin panel link. Over time, templates become decision trees that encode business rules - and those rules become invisible to testing frameworks.
The countermeasure is view models or presenters: objects that accept raw domain data and expose computed, display-ready properties to the template. A ProductPresenter wraps a Product entity and exposes isAvailable, formattedPrice, discountPercentage, and badgeLabel - all pure, testable transformations that keep the template itself declarative.
Coupling Through Shared State
In Node.js SSR applications, the single-process, request-concurrent nature of the runtime creates a subtle trap: sharing mutable state between requests. Unlike PHP's shared-nothing architecture where each request runs in its own process, a Node.js SSR application runs all requests in a single process. Module-level singletons, global caches, and shared objects can bleed state between concurrent requests if not managed carefully.
This is not a problem MVC causes - it is a problem that MVC's clean separation can mask. The discipline of keeping Models stateless and Controllers creating new service instances per request (or using dependency injection with request-scoped lifetimes) is the appropriate safeguard.
Over-Engineering in Small Applications
MVC can be significant overhead for simple applications. A marketing site with five pages, a content API with three endpoints, or a prototype that needs to ship in a week does not necessarily benefit from strict MVC discipline. The pattern introduces indirection - you must navigate between three layers to understand a single request - and that indirection has a cognitive cost.
The trade-off is straightforward: MVC pays for itself as complexity grows. For small, stable, low-traffic applications, the overhead may never be justified. The mistake is applying the pattern reflexively because it is familiar, rather than because the application's complexity warrants it.
Best Practices
Keep controllers thin. A controller action should read like a summary of what happens during a request, not a detailed implementation. If you cannot describe a controller action in one sentence, it is probably doing too much. The test for this is simple: if you were to move the application from HTTP to a CLI or a message queue, would your controller action be reusable? If not, business logic has leaked in.
Make models HTTP-agnostic. This principle enables portability and testability. Every time a model imports a framework's request object or reads from a session, it becomes harder to test, reuse, and reason about. Use dependency injection to pass required context explicitly - a user ID, a locale string, a currency code - rather than letting models reach into the request.
Use view models (presenters) to separate display concerns from domain concerns. Raw domain entities often contain more information than a view needs, or need transformation before display. Creating a thin presenter layer prevents template logic from accumulating and keeps the domain model focused on behavior rather than presentation formatting.
Apply consistent error handling at the controller boundary. Model-layer exceptions (database errors, validation failures, not-found conditions) should be caught at the controller level and translated into appropriate HTTP responses. This keeps error handling centralized and prevents unhandled promise rejections from propagating to Express or similar frameworks' default error handlers.
Invest in integration tests at the controller level. Unit tests for models and views are valuable, but integration tests that fire a real HTTP request through the full stack - controller, model, view - catch the most important class of bugs: mismatched data contracts between layers. Libraries like Supertest for Node.js or pytest-django for Python make this straightforward.
Use dependency injection to manage layer dependencies. Hardcoded new Repository() calls inside controllers create tight coupling that makes testing difficult and infrastructure changes expensive. A simple dependency injection approach - passing repositories or services into controller constructors or factory functions - enables easy mocking and makes the dependency graph explicit.
Establish a clear convention for where request validation lives. Validation is a common area of confusion in MVC: is it a Model concern (validate domain rules) or a Controller concern (validate that the request is well-formed)? A workable answer is to split the two: input validation (are required fields present? is the format correct?) lives in middleware or at the controller boundary; business rule validation (does this user have permission? is this operation currently allowed?) lives in the Model or service layer.
Pros and Cons Summary
Advantages
MVC provides a clear, shared vocabulary for team communication. When a developer says "the problem is in the controller" or "that belongs in the model," everyone understands the intent immediately. This shared language reduces ambiguity in code reviews, design discussions, and onboarding.
The separation of concerns MVC enforces makes the codebase significantly more testable. Models can be unit-tested against a test database without spinning up an HTTP server. Views can be tested with fixture data. Controllers can be integration-tested with mocked repositories. Each layer's tests are focused, fast, and meaningful.
MVC also makes technology substitution easier. Swapping a template engine, migrating from a REST controller to a GraphQL resolver, or moving from a SQL database to a document store are all changes that - in a well-structured MVC application - are largely contained within a single layer.
Disadvantages
The primary cost of MVC is indirection. Understanding a single request requires reading at least three files. For teams unfamiliar with the pattern, this navigation overhead is real and can slow down initial development velocity.
MVC's three-layer model can also be insufficient for complex domains. Real applications often need service layers, domain events, command handlers, and read/write separation. MVC does not provide guidance for these structures, leading teams to either shoe-horn complex logic into one of the three layers or improvise additional patterns without architectural consistency.
Finally, MVC was designed for synchronous, request-response interactions. Real-time features, WebSockets, background jobs, and event-driven processing do not fit naturally into the Controller-Model-View cycle. These patterns require separate architectural thinking, and trying to force them into MVC creates confusion.
Analogies and Mental Models
Think of an MVC SSR application as a restaurant. The customer (the browser) places an order (an HTTP request). The waiter (the Controller) takes the order, relays it to the kitchen (the Model), and brings the finished dish to the table (the View renders and sends HTML). The waiter does not cook the food, and the kitchen does not serve it. Each role has a clear boundary, and the system works because those boundaries are respected.
Extend the analogy: a "fat controller" is a waiter who starts cooking at the table. A "fat model" is a kitchen that also sets the table and greets customers. View logic creep is a dish that comes with cooking instructions printed on the plate. Each violation feels like a small convenience in the moment but breaks the system's clarity and resilience at scale.
A second useful mental model: MVC as a data pipeline. Raw HTTP input -> Controller extracts intent -> Model produces domain data -> View transforms data to HTML -> HTTP response. Each stage transforms data and passes it forward. The discipline is ensuring that no stage reaches backward to consume data from a prior stage's context.
The 80/20 Insight
If you apply nothing else from this article, apply these three principles:
First, keep your controllers to under thirty lines per action. Any more, and something that should be in the Model has drifted up. This single constraint - applied consistently in code review - will prevent the most common MVC failure mode.
Second, treat your Model layer as a library with a public API. If you could publish your models as an npm package or Python library and someone else could use them without knowing they were originally built for a web application, you have achieved the right level of decoupling. If they could not - because models import request objects, read cookies, or format strings for display - you have work to do.
Third, never let template files make decisions about what data to display. Templates should decide how to display data, not what data to show. That conditional if user.role == 'admin' in your template is benign until it is replicated in eight templates and the role system changes. Move it to a presenter, a helper, or a controller, and your templates become durable.
Conclusion
MVC for server-side rendering is neither a relic nor a silver bullet. It is a well-tested architectural vocabulary that solves a specific set of problems - separation of presentation from business logic, testability, and team coordination - with known trade-offs around indirection, complexity ceiling, and domain modeling constraints.
The pattern's value is not in the structure itself but in the discipline it enforces. Teams that apply MVC with genuine architectural intent - maintaining layer boundaries, resisting logic creep, building thin controllers and focused models - build codebases that remain comprehensible and maintainable as they grow. Teams that apply MVC as a folder naming convention tend to inherit a tangled mass of logic that technically obeys the pattern's labels while violating all of its principles.
As SSR continues its resurgence - driven by Core Web Vitals, edge rendering, React Server Components, and the ongoing costs of JavaScript-heavy SPAs - understanding MVC at depth becomes increasingly valuable. The pattern maps cleanly onto the server's role as the authoritative, rendering, data-owning layer. Applied with care, it remains one of the clearest ways to structure that responsibility.
Key Takeaways
- Define layer boundaries explicitly - write a short architectural decision record that states what belongs in each layer. Make it part of your onboarding documentation.
- Audit your controllers for length - any action over forty lines is a refactoring candidate. Extract business logic into service objects or repository methods.
- Introduce presenters for complex views - wherever template logic exceeds simple interpolation, extract a presenter class with testable computed properties.
- Inject dependencies into controllers - avoid
new Repository()inside controller functions. Use a factory function, DI container, or constructor injection to make dependencies visible and swappable. - Write integration tests from the HTTP boundary - a test suite that fires real requests and asserts on the rendered HTML (or JSON) catches the entire MVC stack and provides the highest confidence for changes.
References
- Reenskaug, T. (1979). MVC - XEROX PARC 1978-79. Available at: https://folk.universitetetioslo.no/trygver/themes/mvc/mvc-index.html
- Fowler, M. (2002). Patterns of Enterprise Application Architecture. Addison-Wesley. Chapter 14: Web Presentation Patterns.
- Ruby on Rails Guides - Action Controller Overview. https://guides.rubyonrails.org/action_controller_overview.html
- Django Documentation - MTV (or MVC) FAQ. https://docs.djangoproject.com/en/stable/faq/general/#django-appears-to-be-a-mvc-framework-but-you-call-the-controller-the-view-and-the-view-the-template-how-come-you-don-t-use-the-standard-names
- Next.js Documentation - Data Fetching (getServerSideProps). https://nextjs.org/docs/pages/building-your-application/data-fetching/get-server-side-props
- Next.js Documentation - React Server Components. https://nextjs.org/docs/app/building-your-application/rendering/server-components
- Gamma, E., Helm, R., Johnson, R., Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley.
- Fowler, M. (2006). AnemicDomainModel. https://martinfowler.com/bliki/AnemicDomainModel.html
- Fowler, M. (2004). Presentation Model. https://martinfowler.com/eaaDev/PresentationModel.html
- MDN Web Docs - Server-side web frameworks. https://developer.mozilla.org/en-US/docs/Learn/Server-side/First_steps/Web_frameworks
- Express.js Documentation. https://expressjs.com/en/guide/routing.html
- React Documentation - renderToPipeableStream. https://react.dev/reference/react-dom/server/renderToPipeableStream