A Multi-Page MVC Project
Objectives
By the end of this lesson, you should be able to:
- Build a multi-page, server-rendered app using the full MVC structure from this module
- Wire pre-made CSS into an MVC project with
express.static - Trace a single request, a page view, a form submission, all the way through routes, controller, model, and view
💡 Why this matters: Every MVC example so far in this module returned JSON. Most of what this course has built, EJS and Handlebars views (Module 6), forms and
res.redirect()(Module 6’s CRUD lesson), static files (Module 5), fits into the exact same model/controller/routes structure, this lesson proves it with a real, multi-page site.
⚠️ A note on verification: every snippet and every response shown below was actually run and tested with real HTTP requests, using
express-handlebars9.
Project File Structure
A small recipe box app, four pages (list, detail, new, edit), one layout, one partial, and a complete, pre-made stylesheet:
project/
├── package.json
├── app.js
├── models/
│ └── recipeModel.js
├── controllers/
│ └── recipeController.js
├── routes/
│ └── recipeRoutes.js
├── views/
│ ├── layouts/
│ │ └── main.handlebars
│ ├── partials/
│ │ └── nav.handlebars
│ ├── index.handlebars
│ ├── show.handlebars
│ ├── new.handlebars
│ ├── edit.handlebars
│ └── not-found.handlebars
└── public/
└── css/
└── style.css
public/ sits alongside views/, not inside it, express.static (Module 5) serves it directly by path, completely separate from how express-handlebars finds templates.
The Model
// models/recipeModel.js
let recipes = [
{ id: 1, name: 'Pancakes', minutes: 20, servings: 4, ingredients: ['flour', 'eggs', 'milk', 'butter'] },
{ id: 2, name: 'Garden Salad', minutes: 10, servings: 2, ingredients: ['lettuce', 'tomato', 'cucumber', 'olive oil'] },
{ id: 3, name: 'Tomato Soup', minutes: 35, servings: 4, ingredients: ['tomatoes', 'onion', 'garlic', 'vegetable stock'] }
];
let nextId = 4;
function getAll() { return recipes; }
function getById(id) { return recipes.find(r => r.id === id); }
function create(data) {
const recipe = {
id: nextId++,
name: data.name,
minutes: Number(data.minutes) || 0,
servings: Number(data.servings) || 1,
ingredients: data.ingredients
? data.ingredients.split(',').map(i => i.trim()).filter(Boolean)
: []
};
recipes.push(recipe);
return recipe;
}
function update(id, data) {
const recipe = getById(id);
if (!recipe) return null;
recipe.name = data.name;
recipe.minutes = Number(data.minutes) || 0;
recipe.servings = Number(data.servings) || 1;
recipe.ingredients = data.ingredients
? data.ingredients.split(',').map(i => i.trim()).filter(Boolean)
: [];
return recipe;
}
function remove(id) {
const index = recipes.findIndex(r => r.id === id);
if (index === -1) return false;
recipes.splice(index, 1);
return true;
}
module.exports = { getAll, getById, create, update, remove };
Exactly Lesson 2’s pattern, data.ingredients.split(',') (Module 2’s string methods) is the only new piece, turning a comma-separated form field into a clean array, still just plain JavaScript, no req/res anywhere.
The Controller
// controllers/recipeController.js
const recipeModel = require('../models/recipeModel');
function index(req, res) {
res.render('index', { pageTitle: 'All Recipes', recipes: recipeModel.getAll() });
}
function show(req, res) {
const recipe = recipeModel.getById(Number(req.params.id));
if (!recipe) return res.status(404).render('not-found', { pageTitle: 'Not Found' });
res.render('show', { pageTitle: recipe.name, recipe });
}
function newForm(req, res) {
res.render('new', { pageTitle: 'New Recipe' });
}
function create(req, res) {
recipeModel.create(req.body);
res.redirect('/recipes');
}
function editForm(req, res) {
const recipe = recipeModel.getById(Number(req.params.id));
if (!recipe) return res.status(404).render('not-found', { pageTitle: 'Not Found' });
res.render('edit', { pageTitle: `Edit ${recipe.name}`, recipe, ingredientsText: recipe.ingredients.join(', ') });
}
function update(req, res) {
const recipe = recipeModel.update(Number(req.params.id), req.body);
if (!recipe) return res.status(404).render('not-found', { pageTitle: 'Not Found' });
res.redirect(`/recipes/${recipe.id}`);
}
function destroy(req, res) {
recipeModel.remove(Number(req.params.id));
res.redirect('/recipes');
}
module.exports = { index, show, newForm, create, editForm, update, destroy };
Same shape as every other controller in this module, res.render() (Module 6) and res.redirect() (Module 6’s CRUD lesson) replace res.json(), but the coordinating role is identical, call the model, decide what to send back.
The Routes
// routes/recipeRoutes.js
const express = require('express');
const router = express.Router();
const recipeController = require('../controllers/recipeController');
router.get('/', recipeController.index);
router.get('/new', recipeController.newForm);
router.post('/', recipeController.create);
router.get('/:id', recipeController.show);
router.get('/:id/edit', recipeController.editForm);
router.post('/:id/edit', recipeController.update);
router.post('/:id/delete', recipeController.destroy);
module.exports = router;
Notice GET /new is registered before GET /:id (Module 5’s route-matching-order rule), otherwise a request for /recipes/new would match /:id first, with "new" incorrectly treated as an ID.
The Layout, Partial, and Pre-Made Styles
<!-- views/layouts/main.handlebars -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{pageTitle}} - Recipe Box</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
{{> nav}}
<main class="container">
{{{body}}}
</main>
</body>
</html>
<!-- views/partials/nav.handlebars -->
<header class="site-header">
<div class="container header-inner">
<a href="/recipes" class="brand">Recipe Box</a>
<a href="/recipes/new" class="btn btn-primary">+ New Recipe</a>
</div>
</header>
<link rel="stylesheet" href="/css/style.css"> in the layout (Lesson 5’s every-page-gets-this pattern) points at a pre-made stylesheet, provided in full below, a card grid for the recipe list, styled forms, buttons, and a clean header, entirely written already, so this lesson’s focus stays on the MVC structure connecting everything, not on writing CSS. Copy it into public/css/style.css exactly as it is, and every page already looks finished:
/* public/css/style.css */
:root {
--navy: #0f172a;
--blue: #2563eb;
--blue-dark: #1d4ed8;
--slate: #475569;
--slate-light: #94a3b8;
--border: #e2e8f0;
--bg: #f8fafc;
--red: #dc2626;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
background: var(--bg);
color: var(--navy);
line-height: 1.5;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 0 24px;
}
.site-header {
background: var(--navy);
padding: 18px 0;
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
color: #fff;
font-size: 20px;
font-weight: 700;
text-decoration: none;
}
main.container {
padding-top: 40px;
padding-bottom: 60px;
}
.page-title {
font-size: 32px;
margin: 0 0 8px;
}
.section-title {
font-size: 20px;
margin: 32px 0 12px;
}
.back-link {
display: inline-block;
margin-bottom: 16px;
color: var(--blue);
text-decoration: none;
font-size: 14px;
}
.back-link:hover { text-decoration: underline; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-top: 24px;
}
.card {
display: block;
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.card:hover {
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08);
transform: translateY(-2px);
}
.card-title {
margin: 0 0 6px;
font-size: 18px;
}
.card-meta {
color: var(--slate);
font-size: 14px;
margin: 0;
}
.empty-state {
color: var(--slate);
margin-top: 24px;
}
.ingredient-list {
padding-left: 20px;
}
.ingredient-list li {
margin-bottom: 4px;
}
.actions {
margin-top: 32px;
display: flex;
gap: 12px;
}
.inline-form {
display: inline;
}
.recipe-form {
display: flex;
flex-direction: column;
gap: 6px;
max-width: 420px;
margin-top: 24px;
}
.recipe-form label {
font-size: 13px;
font-weight: 600;
color: var(--slate);
margin-top: 10px;
}
.recipe-form input {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 15px;
}
.recipe-form input:focus {
outline: 2px solid var(--blue);
outline-offset: 1px;
}
.btn {
display: inline-block;
border: none;
border-radius: 8px;
padding: 10px 18px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
text-align: center;
}
.btn-primary {
background: var(--blue);
color: #fff;
margin-top: 20px;
}
.btn-primary:hover { background: var(--blue-dark); }
.btn-secondary {
background: #fff;
border: 1px solid var(--border);
color: var(--navy);
}
.btn-danger {
background: #fff;
border: 1px solid var(--red);
color: var(--red);
}
.btn-danger:hover {
background: var(--red);
color: #fff;
}
The Views
Every template the controller renders, one per page, plus the not-found view already referenced by show and editForm above:
<!-- views/index.handlebars -->
<h1 class="page-title">All Recipes</h1>
{{#if recipes.length}}
<div class="card-grid">
{{#each recipes}}
<a href="/recipes/{{this.id}}" class="card">
<h2 class="card-title">{{this.name}}</h2>
<p class="card-meta">{{this.minutes}} min · serves {{this.servings}}</p>
</a>
{{/each}}
</div>
{{else}}
<p class="empty-state">No recipes yet. <a href="/recipes/new">Add the first one</a>.</p>
{{/if}}
<!-- views/show.handlebars -->
<a href="/recipes" class="back-link">← All recipes</a>
<h1 class="page-title">{{recipe.name}}</h1>
<p class="card-meta">{{recipe.minutes}} min · serves {{recipe.servings}}</p>
<h2 class="section-title">Ingredients</h2>
<ul class="ingredient-list">
{{#each recipe.ingredients}}
<li>{{this}}</li>
{{/each}}
</ul>
<div class="actions">
<a href="/recipes/{{recipe.id}}/edit" class="btn btn-secondary">Edit</a>
<form action="/recipes/{{recipe.id}}/delete" method="POST" class="inline-form">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
<!-- views/new.handlebars -->
<a href="/recipes" class="back-link">← All recipes</a>
<h1 class="page-title">New Recipe</h1>
<form action="/recipes" method="POST" class="recipe-form">
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="minutes">Minutes</label>
<input type="number" id="minutes" name="minutes" min="1" required>
<label for="servings">Servings</label>
<input type="number" id="servings" name="servings" min="1" required>
<label for="ingredients">Ingredients (comma-separated)</label>
<input type="text" id="ingredients" name="ingredients" placeholder="flour, eggs, milk">
<button type="submit" class="btn btn-primary">Create Recipe</button>
</form>
<!-- views/edit.handlebars -->
<a href="/recipes/{{recipe.id}}" class="back-link">← Back to recipe</a>
<h1 class="page-title">Edit Recipe</h1>
<form action="/recipes/{{recipe.id}}/edit" method="POST" class="recipe-form">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{recipe.name}}" required>
<label for="minutes">Minutes</label>
<input type="number" id="minutes" name="minutes" value="{{recipe.minutes}}" min="1" required>
<label for="servings">Servings</label>
<input type="number" id="servings" name="servings" value="{{recipe.servings}}" min="1" required>
<label for="ingredients">Ingredients (comma-separated)</label>
<input type="text" id="ingredients" name="ingredients" value="{{ingredientsText}}">
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
<!-- views/not-found.handlebars -->
<h1 class="page-title">Recipe Not Found</h1>
<p>That recipe doesn't exist. <a href="/recipes">Back to all recipes</a>.</p>
Every one of these renders through the same main layout automatically, {{{body}}} in the layout is filled with whichever of these five templates the controller chose to render, that’s the entire mechanism, no page here repeats the <!DOCTYPE html>, <head>, or navigation, all of that lives in the layout and partial alone. new.handlebars and edit.handlebars are nearly identical, the real difference is edit.handlebars pre-fills every value="..." from the existing recipe, exactly what makes it an edit form rather than a blank one.
curl http://localhost:4701/recipes/1 | grep -E "page-title|min|<li>"
<h1 class="page-title">Pancakes</h1>
<p class="card-meta">20 min · serves 4</p>
<li>flour</li>
<li>eggs</li>
<li>milk</li>
<li>butter</li>
curl -w " [%{http_code}]" -o /dev/null http://localhost:4701/recipes/99
[404]
Trying the Complete App
curl http://localhost:4701/recipes | grep card-title
<h2 class="card-title">Pancakes</h2>
<h2 class="card-title">Garden Salad</h2>
<h2 class="card-title">Tomato Soup</h2>
curl -X POST http://localhost:4701/recipes -d "name=Pasta&minutes=25&servings=3&ingredients=pasta, tomato sauce, basil"
curl http://localhost:4701/recipes | grep card-title
<h2 class="card-title">Pancakes</h2>
<h2 class="card-title">Garden Salad</h2>
<h2 class="card-title">Tomato Soup</h2>
<h2 class="card-title">Pasta</h2>
curl -i http://localhost:4701/css/style.css
HTTP/1.1 200 OK
Content-Type: text/css; charset=UTF-8
Every piece from every earlier module shows up here at once: express.static serves the CSS (Module 5), the router matches paths in the correct order (Module 5), the controller coordinates without containing real logic (Lesson 3), the model owns the data (Lesson 2), and the whole thing renders through a shared layout and partial (Module 6), this is what “everything this course built, organized properly” actually looks like running.
Try It
- Build this project (or a similar one, a bookshelf, a movie watchlist, anything with a list, a detail page, and a create/edit form) using this exact file structure, using a pre-made or your own stylesheet in
public/css/style.css. - Confirm route order matters, temporarily move
GET /:idaboveGET /new, request/recipes/new, and observe it incorrectly try to treat"new"as an ID. - Add a
doneboolean to each recipe (a “made this before” flag), a checkbox innew.handlebars/edit.handlebars, and a badge inindex.handlebarsandshow.handlebarsshown with{{#if this.done}}, wiring the new field through the model, controller, and every view that needs it. - Explain, in your own words, why the CSS file lives in
public/, outsideviews/, even though both are part of what the browser eventually receives.
Recap
- A multi-page, server-rendered app uses the exact same model/controller/routes structure as a JSON API,
res.render()andres.redirect()in place ofres.json(). express.staticserves a pre-made stylesheet independently of the view engine,public/andviews/are separate concerns, wired together only by the<link>tag in the shared layout.- Route registration order (a specific path like
/newbefore a parameterized one like/:id) matters just as much in a multi-page app as it did for the JSON APIs built in Module 9.
Next lesson: this module’s exercises, refactoring a single-file API into a complete MVC project.