Server-Side MVC

Building server-rendered applications with the Model-View-Controller pattern

Introduction

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:

  • Model — handles all data access (SQL queries against PostgreSQL)
  • View — renders HTML using the EJS template engine
  • Controller — connects models and views, handles request logic

Prerequisites

  • Node.js installed (v16 or later) — nodejs.org
  • PostgreSQL running with the stories_demo database from Module 06
  • Familiarity with the database patterns from Module 06

What is EJS?

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) { %>

MVC File Structure

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 (, nav, footer)
    └── stories/
        ├── index.ejs           # List all stories
        ├── show.ejs            # Single story detail
        └── form.ejs            # Create/edit form (dual-purpose)
  • Models — know how to talk to the database. They have no knowledge of HTTP, HTML, or requests.
  • Views — know how to render HTML. They receive data and produce markup. They have no knowledge of the database.
  • Controllers — sit between models and views. They read the request, call the model to get or save data, then pass that data to a view.
  • Routes — map URL patterns to controller methods. They contain no logic of their own.

Building the Model (models/Story.js)

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.

Building the Controller (controllers/storyController.js)

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;

POST/Redirect/GET Pattern

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).

Validation with Re-render

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.

Building the Views

Layout (views/layout.ejs)

The layout provides the shared HTML shell — the <head>, navigation, and footer that every page needs. Individual views are inserted into the body variable:

<%= title || 'Stories App' %> <%- body %>

Notice <%- body %> uses the raw output tag (<%-) because body contains HTML that should not be escaped.

Story List (views/stories/index.ejs)

The list page renders all stories in a table with colored badges for priority and status:

Stories

<% if (stories.length === 0) { %>

No stories yet. Create one.

<% } else { %> <% stories.forEach(story => { %> <% }); %>
Title Priority Status Actions
<%= story.title %> <%= story.priority %> <%= story.status %> Edit
<% } %>

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.
  • The delete button is wrapped in a tiny <form> that POSTs to /stories/:id/delete. This is how we handle deletion without JavaScript or PUT/DELETE methods.
  • CSS class names like badge-high and badge-todo are generated dynamically from the data.

Single Story (views/stories/show.ejs)

<%= story.title %>

<%= story.priority %> priority <%= story.status %>

<% if (story.description) { %>

<%= story.description %>

<% } %>

Created: <%= new Date(story.created_at).toLocaleString() %>

Edit Back to list

Create/Edit Form (views/stories/form.ejs)

A single template handles both create and edit. The isEdit flag controls the form action and button label:

<%= isEdit ? 'Edit Story' : 'New Story' %>

<% if (error) { %>
<%= error %>
<% } %>


Cancel

This dual-purpose pattern is common in MVC applications. The isEdit flag changes three things:

  • The heading ("New Story" vs "Edit Story")
  • The form action (/stories vs /stories/:id)
  • The submit button label ("Create Story" vs "Update Story")

XSS Prevention

EJS's <%= %> tag automatically escapes HTML characters. If someone creates a story with the title:

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.

Routing (routes/stories.js)

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; };

Route Table

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, then redirect
POST /stories/:id update Handle edit, then redirect
POST /stories/:id/delete destroy Handle delete, then redirect

Wiring It Together (app.js)

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.
  • Dependency injection chain: poolStory model → StoryController → routes. Each layer receives its dependencies from the outside, making the code modular and testable.

Running and Testing

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.

REST API vs MVC App — Comparison

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()