08. Client-Side MVC

Introduction

In Module 07, the server did all the rendering. Express loaded data from PostgreSQL, passed it to EJS templates, and sent complete HTML pages to the browser. The browser was passive — it displayed whatever HTML it received.

In this module, we invert that architecture. The server becomes a JSON API (like Module 06), and the browser takes over all rendering. JavaScript running in the browser calls fetch() to get JSON data, then builds the entire user interface by manipulating the DOM. No page reloads. No server-rendered HTML. The browser is in control.

This is a Single-Page Application (SPA). The browser loads one HTML file, and JavaScript handles everything from there.

Module 07 (Server-Side MVC): Module 08 (Client-Side MVC): Browser ──GET /stories──> Server Browser ──fetch('/api/stories')──> Server <──HTML page──── <──JSON array──────────── JS builds DOM locally Browser ──POST /stories──> Server Browser ──fetch('/api/stories', { <──302 Redirect── method: 'POST', ──GET /stories──> body: JSON.stringify(data) <──HTML page──── })──> Server <──JSON object── JS updates DOM locally (no reload)

Prerequisites

Database required: This module uses the same stories_demo database and stories table from Module 06. If you haven’t set that up yet, go back to Module 06 and run the schema setup first.

Demo Files

Run locally: npm install then node server.jshttp://localhost:3008/app.html

Live demo: Try the SPA demo (connects to a live PostgreSQL database)

How a SPA Works

A traditional web app (Module 07) works like this: every user action triggers a full page load. Click a link? New HTML page. Submit a form? POST, redirect, new HTML page. The browser is essentially a document viewer.

A SPA works differently:

  1. The browser loads one HTML page (app.html) with a <script> tag
  2. JavaScript initializes and fetches data from the API using fetch()
  3. JavaScript builds the DOM — creates elements, sets text, appends them to the page
  4. When the user clicks something, JavaScript handles the event, fetches new data, and swaps out the DOM
  5. The browser never navigates to a new URL — the same HTML page stays loaded
1. Browser loads app.html ┌─────────────────────────────────────────┐ │ <div id="content">Loading...</div> │ │ <script src="stories.js"></script> │ └─────────────────────────────────────────┘ 2. stories.js calls fetch('/api/stories') Browser ──GET /api/stories──> Server <──[{id:1, title:"..."}, ...]── 3. JS builds table from JSON and inserts into #content ┌─────────────────────────────────────────┐ │ <div id="content"> │ │ <h1>All Stories</h1> │ │ <table>...rows from JSON...</table> │ │ </div> │ └─────────────────────────────────────────┘ 4. User clicks "Edit" → JS fetches story, builds form (no page reload, just DOM swap)

The SPA Page (app.html)

The HTML shell is minimal. It provides structure and styling, but the #content div is empty — JavaScript fills it:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Stories App (SPA)</title>
    <style>/* All styles inline */</style>
</head>
<body>
    <div id="app">
        <nav>
            <a onclick="StoryController.index()">All Stories</a>
            <a onclick="StoryController.create()">New Story</a>
        </nav>
        <div id="content">
            <p>Loading...</p>
        </div>
        <footer>Stories App &mdash; Client-Side MVC Demo</footer>
    </div>
    <script src="stories.js"></script>
</body>
</html>

Key differences from Module 07’s layout:

Client-Side MVC Structure

The stories.js file is organized into three sections that directly mirror the server-side MVC from Module 07:

MVC Layer Module 07 (Server-Side) Module 08 (Client-Side)
Model models/Story.js — SQL queries via pool.query() StoryModel object — API calls via fetch()
View views/stories/*.ejs — EJS templates rendered on server StoryView object — createElement + textContent in browser
Controller controllers/storyController.js — handles HTTP req/res StoryController object — handles user events + coordinates

The same methods exist in both: index(), show(), create(), store(), edit(), update(), destroy(). The pattern is identical; only the technology changes.

Building the Model

The Model wraps fetch() calls. It knows the API URL and how to send/receive JSON. It knows nothing about the DOM or user interface:

const StoryModel = {
    API_URL: '/api/stories',

    async findAll() {
        const res = await fetch(this.API_URL);
        if (!res.ok) throw new Error('Failed to load stories');
        return res.json();
    },

    async findById(id) {
        const res = await fetch(`${this.API_URL}/${id}`);
        if (!res.ok) throw new Error('Story not found');
        return res.json();
    },

    async create(data) {
        const res = await fetch(this.API_URL, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(data)
        });
        if (!res.ok) {
            const err = await res.json();
            throw new Error(err.error || 'Failed to create story');
        }
        return res.json();
    },

    async update(id, data) {
        const res = await fetch(`${this.API_URL}/${id}`, {
            method: 'PUT',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(data)
        });
        if (!res.ok) {
            const err = await res.json();
            throw new Error(err.error || 'Failed to update story');
        }
        return res.json();
    },

    async delete(id) {
        const res = await fetch(`${this.API_URL}/${id}`, {
            method: 'DELETE'
        });
        if (!res.ok) throw new Error('Failed to delete story');
        return res.json();
    }
};

Key patterns:

Compare to Module 07’s Model: Module 07’s Story class called pool.query() to run SQL. Module 08’s StoryModel calls fetch() to hit the API. The interface is the same — findAll(), findById(), create(), update(), delete() — but the implementation is completely different because the Model now runs in the browser, not on the server.

Building the View

The View creates and updates DOM elements. It knows nothing about fetch() or the API. Each render method clears #content and rebuilds it:

const StoryView = {
    content: document.getElementById('content'),

    renderList(stories, handlers) {
        this.content.innerHTML = '';

        const h = document.createElement('h1');
        h.textContent = 'All Stories';
        this.content.appendChild(h);

        if (stories.length === 0) {
            // ... show "No stories yet" message
            return;
        }

        const table = document.createElement('table');
        // ... build header row ...

        stories.forEach(story => {
            const row = document.createElement('tr');

            // Title cell with click handler
            const titleCell = document.createElement('td');
            const titleLink = document.createElement('a');
            titleLink.textContent = story.title;  // textContent, not innerHTML!
            titleLink.addEventListener('click', () => handlers.onShow(story.id));
            titleCell.appendChild(titleLink);
            row.appendChild(titleCell);

            // ... priority badge, status badge, action buttons ...
        });

        this.content.appendChild(table);
    },

    renderDetail(story, handlers) { /* ... */ },
    renderForm(story, handlers)   { /* ... */ },
    showError(message)            { /* ... */ }
};

Key patterns:

Compare to Module 07’s Views: Module 07 used EJS templates (index.ejs, show.ejs, form.ejs). Module 08 uses renderList(), renderDetail(), and renderForm() methods that build the same UI with JavaScript. The visual result is the same — tables, badges, forms — but the rendering happens in the browser instead of on the server.

Building the Controller

The Controller coordinates Model and View. It has the same method names as Module 07’s controller, but instead of calling res.render() or res.redirect(), it calls View methods and passes handler callbacks:

const StoryController = {

    async init() {
        await this.index();
    },

    async index() {
        try {
            const stories = await StoryModel.findAll();
            StoryView.renderList(stories, {
                onShow:   (id) => this.show(id),
                onEdit:   (id) => this.edit(id),
                onDelete: (id) => this.destroy(id),
                onCreate: ()   => this.create()
            });
        } catch (err) {
            StoryView.showError(err.message);
        }
    },

    async show(id) {
        try {
            const story = await StoryModel.findById(id);
            StoryView.renderDetail(story, {
                onEdit: (id) => this.edit(id),
                onBack: ()  => this.index()
            });
        } catch (err) {
            StoryView.showError(err.message);
        }
    },

    create() {
        StoryView.renderForm(null, {
            onSubmit: (data) => this.store(data),
            onCancel: ()     => this.index()
        });
    },

    async store(formData) {
        // Client-side validation
        if (!formData.title || formData.title.trim() === '') {
            StoryView.showError('Title is required');
            return;
        }
        try {
            await StoryModel.create(formData);
            await this.index();
        } catch (err) {
            StoryView.showError(err.message);
        }
    },

    async edit(id) {
        const story = await StoryModel.findById(id);
        StoryView.renderForm(story, {
            onSubmit: (data) => this.update(id, data),
            onCancel: ()     => this.show(id)
        });
    },

    async update(id, formData) {
        if (!formData.title || formData.title.trim() === '') {
            StoryView.showError('Title is required');
            return;
        }
        await StoryModel.update(id, formData);
        await this.show(id);
    },

    async destroy(id) {
        if (!confirm('Delete this story?')) return;
        await StoryModel.delete(id);
        await this.index();
    }
};

The flow for each operation:

  1. User clicks something (e.g., “Edit” button)
  2. View calls the handler callback: handlers.onEdit(story.id)
  3. Controller method runs: edit(id)
  4. Controller calls Model: StoryModel.findById(id)
  5. Model calls API: fetch('/api/stories/5')
  6. API returns JSON
  7. Controller calls View: StoryView.renderForm(story, handlers)
  8. View builds DOM — form appears with pre-filled values
No more POST/Redirect/GET: In Module 07, after creating a story the server responded with a 302 redirect, and the browser made a second GET request. In Module 08, after creating a story the Controller simply calls this.index() which fetches the updated list and re-renders — no redirect, no page reload.

The API Server (server.js)

The server is nearly identical to Module 06’s db-demo.js. The only addition is express.static() to serve the SPA files:

const express = require('express');
const { Pool } = require('pg');
const path = require('path');

const app = express();
const PORT = process.env.PORT || 3008;

// PostgreSQL connection (same as Module 06)
const pool = new Pool({ /* ... */ });

// Middleware
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));  // Serve app.html + stories.js

// API routes (identical to Module 06)
app.get('/api/stories', async (req, res) => { /* ... */ });
app.get('/api/stories/:id', async (req, res) => { /* ... */ });
app.post('/api/stories', async (req, res) => { /* ... */ });
app.put('/api/stories/:id', async (req, res) => { /* ... */ });
app.delete('/api/stories/:id', async (req, res) => { /* ... */ });

app.listen(PORT, () => {
    console.log(`Client-Side MVC app at http://localhost:${PORT}/app.html`);
});

The server has two jobs:

  1. Serve static filesapp.html and stories.js from the public/ directory
  2. Serve JSON API — the same CRUD endpoints from Module 06

Notice the server no longer has EJS, views, controllers, or routes files. All of that logic moved to the browser. The server is just a data layer.

Running and Testing

1. Install dependencies:

cd node-tutorial/08-client-mvc
npm install

2. Start the server:

node server.js

You should see:

Connected to PostgreSQL
Client-Side MVC app running at http://localhost:3008
  SPA:  http://localhost:3008/app.html
  API:  http://localhost:3008/api/stories

3. Open your browser and go to http://localhost:3008/app.html. The story list loads without any page refresh.

4. Create a story: Click “New Story” in the nav bar. Fill in the form and click “Create Story”. The list reappears with your new story — no page reload.

5. Edit a story: Click “Edit” on any story. Change the title and click “Update Story”. You’re taken to the detail view with the updated data — no page reload.

6. Delete a story: Click “Delete”. Confirm the dialog. The story disappears from the list — no page reload.

7. Open the Network tab in your browser’s DevTools. You should see fetch requests using GET, POST, PUT, and DELETE methods — all returning JSON. No HTML responses, no 302 redirects.

Contrast with Module 07: In Module 07, every action caused the browser’s address bar to change and the page to reload. Here, the address bar stays on app.html the entire time. All “navigation” is just JavaScript swapping the contents of #content.

XSS in JavaScript

In Module 07, EJS’s <%= %> tag auto-escaped HTML. In client-side JavaScript, you must handle this yourself.

The key rule: use textContent, not innerHTML, when inserting user data:

// SAFE: textContent treats everything as plain text
const td = document.createElement('td');
td.textContent = story.title;  // <script> becomes visible text

// DANGEROUS: innerHTML interprets HTML tags
const td = document.createElement('td');
td.innerHTML = story.title;    // <script>alert('xss')</script> EXECUTES!

textContent sets the text content of an element. It does not parse HTML. If story.title contains <script>alert('xss')</script>, it appears as literal text on the page.

innerHTML parses its value as HTML. If user data contains <script> tags or event handlers like onerror, the browser will execute them.

Never use innerHTML with user data. Use textContent for text, createElement + setAttribute for structure. The only safe use of innerHTML is with static, developer-written strings (like clearing a container with innerHTML = '').

Test this in the app: create a story with the title <script>alert('xss')</script>. Because the View uses textContent, it displays as text instead of executing.

Server-Side vs Client-Side MVC — Comparison

Aspect Module 07 Server-Side MVC Module 08 Client-Side MVC
Server response Full HTML pages JSON data only
Rendering Server (EJS templates) Browser (JavaScript DOM)
Navigation Full page reload DOM swap (no reload)
Form submission HTML form POST → redirect fetch() POST → DOM update
HTTP methods used GET + POST only GET, POST, PUT, DELETE
Data format sent URL-encoded form data JSON body
Validation feedback Server re-renders form JS updates DOM immediately
Works without JS? Yes No
Body parser express.urlencoded() express.json()
XSS prevention EJS <%= %> auto-escaping textContent (manual)
Neither approach is better — they optimize for different things. Server-side MVC works without JavaScript and is better for content-heavy sites, SEO, and accessibility. Client-side MVC provides a smoother user experience with no page reloads and is better for interactive, app-like interfaces. In Module 09, we’ll explore a hybrid approach that combines the strengths of both.