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
- Node.js installed (v16 or later) — nodejs.org
- PostgreSQL running with the
stories_demodatabase from Module 06
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:
- The browser loads one HTML page (
app.html) with a<script>tag - JavaScript initializes and fetches data from the API using
fetch() - JavaScript builds the DOM — creates elements, sets text, appends them to the page
- When the user clicks something, JavaScript handles the event, fetches new data, and swaps out the DOM
- The browser never navigates to a new URL — the same HTML page stays loaded
1. Browser loads app.html ┌─────────────────────────────────────────┐ │Loading...│ │ │ └─────────────────────────────────────────┘ 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 ┌─────────────────────────────────────────┐ ││ ││ └─────────────────────────────────────────┘ 4. User clicks "Edit" → JS fetches story, builds form (no page reload, just DOM swap)All Stories
│ │...rows from JSON...
│ │
The SPA Page (app.html)
The HTML shell is minimal. It provides structure and styling, but the #content div is empty — JavaScript fills it:
Loading...
Key differences from Module 07's layout:
- Navigation uses
onclickhandlers that call controller methods directly — nohref, no page navigation - The
#contentdiv starts nearly empty. JavaScript fills it. - No server-side template engine. No EJS. Just plain HTML.
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:
Key patterns:
async/await— every method is async becausefetch()returns a Promiseresponse.ok— check before parsing.fetch()doesn't throw on 404 or 500 — you have to check manuallyJSON.stringify()— convert JavaScript objects to JSON strings for the request bodyres.json()— parse the JSON response body back into a JavaScript object- All four HTTP methods — GET, POST, PUT, DELETE. Module 07 was limited to GET and POST because HTML forms don't support PUT/DELETE. Since we're using
fetch(), we can use any method.
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:
Key patterns:
document.createElement()— builds elements programmatically instead of writing HTML stringstextContent(notinnerHTML) — safely sets text without interpreting HTML. This prevents XSS.- Event handlers via callbacks — the View receives handler functions from the Controller (e.g.,
handlers.onShow). It doesn't know what happens when a button is clicked — it just calls the handler. this.content.innerHTML = ''— clears the content area before rendering. This is the "swap" that replaces page navigation.
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:
The flow for each operation:
- User clicks something (e.g., "Edit" button)
- View calls the handler callback:
handlers.onEdit(story.id) - Controller method runs:
edit(id) - Controller calls Model:
StoryModel.findById(id) - Model calls API:
fetch('/api/stories/5') - API returns JSON
- Controller calls View:
StoryView.renderForm(story, handlers) - View builds DOM — form appears with pre-filled values
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:
The server has two jobs:
- Serve static files —
app.htmlandstories.jsfrom thepublic/directory - 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:
2. Start the server:
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.
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:
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.
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, then redirect | fetch() POST, then 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) |