07. Server-Side MVC

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:

Prerequisites

Database required: This module uses the same stories_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.

Demo Files

Run: npm install then node app.jshttp://localhost:3007/stories

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) { %>
PHP comparison: EJS’s <%= %> 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.

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 (<head>, nav, footer)
    └── stories/
        ├── index.ejs           # List all stories
        ├── show.ejs            # Single story detail
        └── form.ejs            # Create/edit form (dual-purpose)

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.

Why a class? The constructor receives the database 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.

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:

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

Story List (views/stories/index.ejs)

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:

Single Story (views/stories/show.ejs)

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

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:

<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:

XSS Prevention

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

<script>alert('xss')</script>

EJS renders it as:

&lt;script&gt;alert('xss')&lt;/script&gt;

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 → redirect
POST /stories/:id update Handle edit → redirect
POST /stories/:id/delete destroy Handle delete → redirect
Route order matters: /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'.
Why POST instead of PUT/DELETE? HTML <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.

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:

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.

No curl needed: Unlike Module 06, everything happens in the browser. This is the whole point of server-side rendering — the server produces complete HTML pages that any browser can display without JavaScript.

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()
Neither approach is better — they serve different purposes. REST APIs are for machine-to-machine communication: mobile apps, single-page applications, and third-party integrations. MVC apps are for human users who interact through a browser. In Module 08, we’ll combine both approaches in a single application.