In Module 06 we built a JSON API backed by PostgreSQL. That API is designed for machine-to-machine communication — you test it with curl or Postman, and a front-end JavaScript application would consume it. But what if we want to serve complete HTML pages directly from the server, with forms that users can interact with in a plain web browser?
In this module we build a server-rendered MVC application. Instead of returning JSON, our routes render full HTML pages. Instead of accepting JSON request bodies, our forms submit URL-encoded data. Instead of relying on PUT and DELETE, we work within the constraints of HTML forms (which only support GET and POST).
This module demonstrates the Model-View-Controller pattern:
stories_demo database from Module 06stories_demo database and stories table created in Module 06. If you haven’t set that up yet, go back to Module 06 and run the schema setup first.
Run: npm install then node app.js → http://localhost:3007/stories
EJS (Embedded JavaScript) is a template engine that lets you generate HTML with plain JavaScript. If you’ve used PHP, the concept is identical — you embed code inside your HTML markup. The difference is the tag syntax:
| EJS Tag | Purpose | Example |
|---|---|---|
<%= %> |
Output with HTML escaping (XSS safe) | <%= story.title %> |
<%- %> |
Output raw HTML (used for includes) | <%- include('../layout') %> |
<% %> |
Control flow (no output) | <% if (stories.length) { %> |
<%= %> is like PHP’s <?= htmlspecialchars($var) ?>, and <% %> is like PHP’s <?php ?>. The key advantage of <%= %> is that HTML escaping is automatic — you don’t have to remember to call an escape function.
Here is how the files are organized. Each layer has a single, clear responsibility:
07-server-mvc/
├── app.js # Entry point: wires everything together
├── package.json # Dependencies: express, ejs, pg
├── models/
│ └── Story.js # Data access: SQL queries
├── controllers/
│ └── storyController.js # Logic: validates, calls model, picks view
├── routes/
│ └── stories.js # Routing: maps URLs to controller methods
└── views/
├── layout.ejs # Shared HTML shell (<head>, nav, footer)
└── stories/
├── index.ejs # List all stories
├── show.ejs # Single story detail
└── form.ejs # Create/edit form (dual-purpose)
The model encapsulates all database queries in a single class. Each method takes the data it needs as arguments and returns the query result. The model knows nothing about HTTP requests, responses, or HTML — it only speaks SQL.
class Story {
constructor(pool) {
this.pool = pool;
}
async findAll() {
const result = await this.pool.query(
'SELECT * FROM stories ORDER BY created_at DESC'
);
return result.rows;
}
async findById(id) {
const result = await this.pool.query(
'SELECT * FROM stories WHERE id = $1',
[id]
);
return result.rows[0] || null;
}
async create(data) {
const result = await this.pool.query(
`INSERT INTO stories (title, description, priority, status)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[
data.title.trim(),
data.description || null,
data.priority || 'medium',
data.status || 'todo'
]
);
return result.rows[0];
}
async update(id, data) {
const result = await this.pool.query(
`UPDATE stories
SET title = COALESCE($1, title),
description = COALESCE($2, description),
priority = COALESCE($3, priority),
status = COALESCE($4, status)
WHERE id = $5
RETURNING *`,
[
data.title ? data.title.trim() : null,
data.description !== undefined ? data.description : null,
data.priority || null,
data.status || null,
id
]
);
return result.rows[0] || null;
}
async delete(id) {
const result = await this.pool.query(
'DELETE FROM stories WHERE id = $1 RETURNING *',
[id]
);
return result.rows[0] || null;
}
}
module.exports = Story;
Compare this to Module 06: the SQL queries are identical. The difference is that they are now encapsulated in a class instead of scattered across route handlers. This makes the code easier to test, reuse, and modify. If you need to change how stories are fetched, you change it in one place.
pool as a dependency. This is called dependency injection — the model doesn’t create its own database connection; it receives one from the outside. This makes it easy to swap in a test database or mock during unit testing.
The controller is the middleman. It reads the HTTP request, calls the model to get or save data, then tells Express which view to render (or where to redirect). Here is the full controller:
class StoryController {
constructor(storyModel) {
this.Story = storyModel;
}
// GET /stories - List all stories
async index(req, res) {
try {
const stories = await this.Story.findAll();
res.render('stories/index', { stories });
} catch (err) {
console.error('Error loading stories:', err.message);
res.status(500).send('Server error');
}
}
// GET /stories/new - Show empty create form
async create(req, res) {
res.render('stories/form', {
story: {},
isEdit: false,
error: null
});
}
// GET /stories/:id - Show one story
async show(req, res) {
try {
const story = await this.Story.findById(req.params.id);
if (!story) {
return res.status(404).send('Story not found');
}
res.render('stories/show', { story });
} catch (err) {
console.error('Error loading story:', err.message);
res.status(500).send('Server error');
}
}
// GET /stories/:id/edit - Show pre-filled edit form
async edit(req, res) {
try {
const story = await this.Story.findById(req.params.id);
if (!story) {
return res.status(404).send('Story not found');
}
res.render('stories/form', {
story,
isEdit: true,
error: null
});
} catch (err) {
console.error('Error loading story:', err.message);
res.status(500).send('Server error');
}
}
// POST /stories - Handle create form submission
async store(req, res) {
try {
const { title, description, priority, status } = req.body;
// Validate required field
if (!title || title.trim() === '') {
return res.render('stories/form', {
story: req.body,
isEdit: false,
error: 'Title is required'
});
}
await this.Story.create({ title, description, priority, status });
res.redirect('/stories');
} catch (err) {
console.error('Error creating story:', err.message);
res.status(500).send('Server error');
}
}
// POST /stories/:id - Handle edit form submission
async update(req, res) {
try {
const { title, description, priority, status } = req.body;
if (!title || title.trim() === '') {
return res.render('stories/form', {
story: { ...req.body, id: req.params.id },
isEdit: true,
error: 'Title is required'
});
}
const story = await this.Story.update(req.params.id, {
title, description, priority, status
});
if (!story) {
return res.status(404).send('Story not found');
}
res.redirect(`/stories/${story.id}`);
} catch (err) {
console.error('Error updating story:', err.message);
res.status(500).send('Server error');
}
}
// POST /stories/:id/delete - Handle delete
async destroy(req, res) {
try {
const story = await this.Story.delete(req.params.id);
if (!story) {
return res.status(404).send('Story not found');
}
res.redirect('/stories');
} catch (err) {
console.error('Error deleting story:', err.message);
res.status(500).send('Server error');
}
}
}
module.exports = StoryController;
Notice that store(), update(), and destroy() all end with res.redirect() instead of rendering a view directly. This is the POST/Redirect/GET (PRG) pattern, and it solves a real usability problem:
Browser Server │ │ │ POST /stories │ │ ──────────────────────> │ Controller: validate, save to DB │ │ │ 302 Redirect /stories │ │ <────────────────────── │ Redirect prevents duplicate POST on refresh │ │ │ GET /stories │ │ ──────────────────────> │ Controller: fetch all, render list │ │ │ 200 OK (HTML page) │ │ <────────────────────── │ Browser shows the updated list
Without the redirect, if the user refreshes the page after creating a story, the browser would re-submit the POST request and create a duplicate. With PRG, a refresh simply re-fetches the list page (a safe GET request).
There is one important exception to the redirect rule: when validation fails. If the user submits a form with an empty title, the controller re-renders the form (not a redirect) with an error message and the previously entered data:
// Validation failed - re-render the form (no redirect)
if (!title || title.trim() === '') {
return res.render('stories/form', {
story: req.body, // preserve what the user typed
isEdit: false,
error: 'Title is required'
});
}
This way the user doesn’t lose their input. They see the error, fix the title, and resubmit.
The layout provides the shared HTML shell — the <head>, navigation, and footer that every page needs. Individual views are inserted into the body variable:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title || 'Stories App' %></title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
nav { background: #27ae60; padding: 10px 20px; border-radius: 4px; margin-bottom: 20px; }
nav a { color: white; text-decoration: none; margin-right: 15px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 10px; text-align: left; }
th { background: #27ae60; color: white; }
.btn { padding: 6px 14px; border: none; border-radius: 4px; cursor: pointer;
text-decoration: none; font-size: 14px; }
.btn-primary { background: #27ae60; color: white; }
.btn-danger { background: #e74c3c; color: white; }
.btn-secondary { background: #95a5a6; color: white; }
.badge { padding: 3px 8px; border-radius: 10px; font-size: 12px; color: white; }
.badge-high { background: #e74c3c; }
.badge-medium { background: #f39c12; }
.badge-low { background: #27ae60; }
.badge-todo { background: #3498db; }
.badge-in-progress { background: #f39c12; }
.badge-done { background: #27ae60; }
.error { background: #fdecea; border: 1px solid #e74c3c; padding: 10px;
border-radius: 4px; margin-bottom: 15px; color: #c0392b; }
form label { display: block; margin-top: 12px; font-weight: bold; }
form input, form select, form textarea {
width: 100%; padding: 8px; margin-top: 4px;
border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box;
}
form textarea { height: 100px; resize: vertical; }
</style>
</head>
<body>
<nav>
<a href="/stories">All Stories</a>
<a href="/stories/new">New Story</a>
</nav>
<%- body %>
</body>
</html>
Notice <%- body %> uses the raw output tag (<%-) because body contains HTML that should not be escaped.
The list page renders all stories in a table with colored badges for priority and status:
<h1>Stories</h1>
<% if (stories.length === 0) { %>
<p>No stories yet. <a href="/stories/new">Create one</a>.</p>
<% } else { %>
<table>
<tr>
<th>Title</th>
<th>Priority</th>
<th>Status</th>
<th>Actions</th>
</tr>
<% stories.forEach(story => { %>
<tr>
<td><a href="/stories/<%= story.id %>"><%= story.title %></a></td>
<td><span class="badge badge-<%= story.priority %>"><%= story.priority %></span></td>
<td><span class="badge badge-<%= story.status %>"><%= story.status %></span></td>
<td>
<a href="/stories/<%= story.id %>/edit" class="btn btn-secondary">Edit</a>
<form action="/stories/<%= story.id %>/delete" method="POST"
style="display:inline">
<button class="btn btn-danger">Delete</button>
</form>
</td>
</tr>
<% }); %>
</table>
<% } %>
Key points:
<%= story.title %> auto-escapes the title. If someone created a story called <script>alert('xss')</script>, it would render as harmless text, not execute as JavaScript.<form> that POSTs to /stories/:id/delete. This is how we handle deletion without JavaScript or PUT/DELETE methods.badge-high and badge-todo are generated dynamically from the data.<h1><%= story.title %></h1>
<p><span class="badge badge-<%= story.priority %>"><%= story.priority %> priority</span>
<span class="badge badge-<%= story.status %>"><%= story.status %></span></p>
<% if (story.description) { %>
<p><%= story.description %></p>
<% } %>
<p><small>Created: <%= new Date(story.created_at).toLocaleString() %></small></p>
<a href="/stories/<%= story.id %>/edit" class="btn btn-secondary">Edit</a>
<a href="/stories" class="btn btn-primary">Back to list</a>
A single template handles both create and edit. The isEdit flag controls the form action and button label:
<h1><%= isEdit ? 'Edit Story' : 'New Story' %></h1>
<% if (error) { %>
<div class="error"><%= error %></div>
<% } %>
<form method="POST"
action="<%= isEdit ? '/stories/' + story.id : '/stories' %>">
<label for="title">Title *</label>
<input type="text" id="title" name="title"
value="<%= story.title || '' %>" required>
<label for="description">Description</label>
<textarea id="description" name="description"
><%= story.description || '' %></textarea>
<label for="priority">Priority</label>
<select id="priority" name="priority">
<option value="low" <%= story.priority === 'low' ? 'selected' : '' %>>Low</option>
<option value="medium" <%= story.priority === 'medium' || !story.priority ? 'selected' : '' %>>Medium</option>
<option value="high" <%= story.priority === 'high' ? 'selected' : '' %>>High</option>
</select>
<label for="status">Status</label>
<select id="status" name="status">
<option value="todo" <%= story.status === 'todo' || !story.status ? 'selected' : '' %>>To Do</option>
<option value="in-progress" <%= story.status === 'in-progress' ? 'selected' : '' %>>In Progress</option>
<option value="done" <%= story.status === 'done' ? 'selected' : '' %>>Done</option>
</select>
<br><br>
<button type="submit" class="btn btn-primary">
<%= isEdit ? 'Update Story' : 'Create Story' %>
</button>
<a href="/stories" class="btn btn-secondary">Cancel</a>
</form>
This dual-purpose pattern is common in MVC applications. The isEdit flag changes three things:
/stories vs /stories/:id)EJS’s <%= %> tag automatically escapes HTML characters. If someone creates a story with the title:
<script>alert('xss')</script>
EJS renders it as:
<script>alert('xss')</script>
The browser displays the text literally instead of executing it as JavaScript. This is your primary defense against Cross-Site Scripting (XSS) attacks. Always use <%= %> for user-provided data. Only use <%- %> (raw output) for trusted HTML like layout includes.
The routes file maps URL patterns to controller methods. It contains no business logic — just wiring:
const express = require('express');
module.exports = function(controller) {
const router = express.Router();
// Display routes (GET)
router.get('/', (req, res) => controller.index(req, res));
router.get('/new', (req, res) => controller.create(req, res));
router.get('/:id', (req, res) => controller.show(req, res));
router.get('/:id/edit', (req, res) => controller.edit(req, res));
// Action routes (POST)
router.post('/', (req, res) => controller.store(req, res));
router.post('/:id', (req, res) => controller.update(req, res));
router.post('/:id/delete', (req, res) => controller.destroy(req, res));
return router;
};
| Method | URL | Controller Method | Purpose |
|---|---|---|---|
| GET | /stories | index | List all stories |
| GET | /stories/new | create | Show empty form |
| GET | /stories/:id | show | Show one story |
| GET | /stories/:id/edit | edit | Show pre-filled form |
| POST | /stories | store | Handle create → redirect |
| POST | /stories/:id | update | Handle edit → redirect |
| POST | /stories/:id/delete | destroy | Handle delete → redirect |
/new must come before /:id. If /:id were first, Express would treat the literal string “new” as an ID parameter and try to look up a story with id = 'new'.
<form> elements only support method="GET" and method="POST". There is no method="PUT" or method="DELETE". Some frameworks work around this with a hidden _method field, but for simplicity we use distinct POST URLs: POST /stories/:id for update and POST /stories/:id/delete for delete.
The entry point creates the database pool, instantiates the model and controller, and connects everything to Express:
const express = require('express');
const { Pool } = require('pg');
const path = require('path');
const Story = require('./models/Story');
const StoryController = require('./controllers/storyController');
const storyRoutes = require('./routes/stories');
const app = express();
// --- Database ---
const pool = new Pool({
host: process.env.PGHOST || 'localhost',
user: process.env.PGUSER || process.env.USER,
password: process.env.PGPASSWORD || '',
database: process.env.PGDATABASE || 'stories_demo',
port: parseInt(process.env.PGPORT) || 5432
});
// --- View engine ---
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// --- Middleware ---
app.use(express.urlencoded({ extended: true })); // Parse form data
// --- Dependency injection ---
const storyModel = new Story(pool);
const storyController = new StoryController(storyModel);
// --- Routes ---
app.use('/stories', storyRoutes(storyController));
// Redirect root to stories list
app.get('/', (req, res) => res.redirect('/stories'));
// --- Start ---
const PORT = process.env.PORT || 3007;
app.listen(PORT, () => {
console.log(`MVC app running at http://localhost:${PORT}/stories`);
});
Key things to notice:
express.urlencoded({ extended: true }) — parses HTML form submissions. In Module 06 we used express.json() because the API accepted JSON. Here the browser sends form data in application/x-www-form-urlencoded format.app.set('view engine', 'ejs') — tells Express to use EJS for rendering. When a controller calls res.render('stories/index', { stories }), Express looks for views/stories/index.ejs.pool → Story model → StoryController → routes. Each layer receives its dependencies from the outside, making the code modular and testable.Step by step:
1. Install dependencies:
cd node-tutorial/07-server-mvc npm install
2. Start the server:
node app.js
You should see:
MVC app running at http://localhost:3007/stories
3. Open your browser and go to http://localhost:3007/stories. You should see the stories from Module 06’s seed data displayed in an HTML table.
4. Create a story: Click “New Story” in the navigation bar. Fill in the form and submit. You’ll be redirected back to the list with your new story visible.
5. View a story: Click any story title to see its detail page.
6. Edit a story: Click “Edit” on any story. The form is pre-filled with the current values. Change something and submit.
7. Delete a story: Click “Delete” on any story. It disappears from the list.
8. Persistence check: Stop the server with Ctrl+C. Start it again with node app.js. Refresh the browser — all your data is still there. The database survives server restarts.
| Aspect | Module 06 REST API | Module 07 MVC App |
|---|---|---|
| Response format | JSON | Server-rendered HTML |
| Client | curl, Postman, JavaScript | Web browser |
| Data submission | JSON body | HTML form (URL-encoded) |
| HTTP methods | GET, POST, PUT, DELETE | GET and POST only |
| After creating | Returns 201 + JSON | Redirects to list page |
| Validation error | Returns 400 + JSON | Re-renders form with error |
| User interface | None (API only) | Full HTML pages |
| Body parser | express.json() |
express.urlencoded() |