Adaptable MVC

Progressive enhancement with content negotiation — HTML and JSON from a single codebase

Introduction

In Module 07, the server always rendered HTML. Every request got a complete page back. In Module 08, the server always returned JSON, and JavaScript rendered everything in the browser. Each approach had trade-offs: Module 07 works without JavaScript but reloads on every action; Module 08 feels smooth but requires JavaScript to function at all.

In this module, we build a single codebase that does both. The controller checks what the client wants and responds accordingly:

  • Browser without JS → requests HTML, gets server-rendered pages (Module 07 behavior)
  • Browser with JSenhance.js intercepts clicks, sends fetch() with Accept: application/json, gets JSON, renders client-side (Module 08 behavior)

This is progressive enhancement: the base experience works everywhere, and JavaScript makes it better when available.

Module 07: Browser ──GET /stories──> Server ──> HTML (always)
Module 08: Browser ──fetch('/api/stories')──> Server ──> JSON (always)
Module 09: Browser ──GET /stories──> Server ──> HTML or JSON (depends on Accept header)
             JS enabled?  → enhance.js intercepts, fetches JSON, renders DOM
             JS disabled? → normal links/forms, server renders HTML

Prerequisites

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

The Adaptive Pattern

The key insight is content negotiation. HTTP has an Accept header that tells the server what format the client wants. Browsers send Accept: text/html by default. When enhance.js makes a fetch() call, it sets Accept: application/json.

The controller checks this header and branches:

                    ┌──────────────────┐
                    │    Controller     │
                    │    index()        │
                    └────────┬─────────┘
                             │
                    ┌────────┴─────────┐
                    │  wantsJSON(req)?  │
                    └────────┬─────────┘
                   yes       │       no
              ┌──────────────┴──────────────┐
              │                             │
         res.json(stories)          res.render('layout', {
              │                        body: indexView
         JSON response              })
              │                             │
         enhance.js                   Full HTML page
         renders DOM                  (browser displays)

Detection: How the Controller Knows

Express provides req.accepts() which does content negotiation based on the Accept header:

wantsJSON(req) { return req.accepts(['html', 'json']) === 'json'; }

This returns 'json' when the client prefers JSON (like our fetch() calls), and 'html' when it prefers HTML (like normal browser navigation). It handles quality values, wildcards, and edge cases automatically.

The enhance.js script explicitly sets the header on every request:

fetch(url, { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } });

The Adaptive Controller

Compare the Module 07 and Module 09 controllers side by side. The Model and Views are unchanged — only the controller gains branching logic:

Module 07 (HTML only)

async index(req, res) { const stories = await this.Story.findAll(); res.render('layout', { title: 'All Stories', body: await renderToString(req.app, 'stories/index', { stories }) }); }

Module 09 (Adaptive)

async index(req, res) { const stories = await this.Story.findAll(); if (this.wantsJSON(req)) { return res.json(stories); // ← JSON path (for fetch) } res.render('layout', { // ← HTML path (for browsers) title: 'All Stories', body: await renderToString(req.app, 'stories/index', { stories }) }); }

The same pattern applies to every action. Here's store() (create):

async store(req, res) { const { title, description, priority, status } = req.body; if (!title || title.trim() === '') { if (this.wantsJSON(req)) { return res.status(400).json({ error: 'Title is required' }); } // Re-render form with error (same as Module 07) return res.render('layout', { /* ... */ }); } const story = await this.Story.create({ title, description, priority, status }); if (this.wantsJSON(req)) { return res.status(201).json(story); // ← JSON response } res.redirect('/stories'); // ← PRG for HTML forms }

And destroy() (delete):

async destroy(req, res) { const deleted = await this.Story.delete(req.params.id); if (!deleted) { if (this.wantsJSON(req)) return res.status(404).json({ error: 'Story not found' }); return res.status(404).send('Story not found'); } if (this.wantsJSON(req)) { return res.json({ message: 'Story deleted' }); } res.redirect('/stories'); }

Routes: Supporting Both Patterns

HTML forms can only send GET and POST requests. But fetch() can use PUT and DELETE. Our routes support both:

// HTML form routes (GET + POST) — same as Module 07 router.get('/', ctrl.index); router.get('/new', ctrl.create); router.get('/:id', ctrl.show); router.get('/:id/edit', ctrl.edit); router.post('/', ctrl.store); router.post('/:id', ctrl.update); router.post('/:id/delete', ctrl.destroy); // JSON API routes (PUT + DELETE for fetch clients) router.put('/:id', ctrl.update); router.delete('/:id', ctrl.destroy);

The same controller methods handle both. router.post('/:id') and router.put('/:id') both call ctrl.update. The controller's wantsJSON() check determines the response format.

The Views: Unchanged

The EJS templates (index.ejs, show.ejs, form.ejs) are reused from Module 07 without changes. The only layout modification is wrapping the body in a <div id="content"> and adding the enhance.js script:

<%- body %>

The id="content" gives enhance.js a target element. On first page load, the server-rendered HTML is already inside this div. The script just attaches event listeners to enhance subsequent interactions.

Progressive Enhancement with enhance.js

The enhance.js script is an IIFE (Immediately Invoked Function Expression) that activates when JavaScript is available. It has four parts:

1. API Helper

A wrapper around fetch() that sets the Accept: application/json header on every request:

function api(url, options) { options = options || {}; options.headers = Object.assign({ 'Accept': 'application/json', 'Content-Type': 'application/json' }, options.headers); return fetch(url, options).then(function(res) { if (!res.ok) { return res.json().then(function(err) { throw new Error(err.error || 'Request failed'); }); } return res.json(); }); }

This is what triggers the JSON branch in the controller. Without this header, the controller would return HTML.

2. Render Functions

These build DOM using createElement + textContent — the same XSS-safe pattern from Module 08:

function renderList(stories) { content.innerHTML = ''; // Build table with createElement, set text with textContent stories.forEach(function(story) { var titleLink = document.createElement('a'); titleLink.textContent = story.title; // Safe against XSS titleLink.addEventListener('click', function(e) { e.preventDefault(); loadShow(story.id); }); // ... }); }

3. Navigation Functions

Simple wrappers that call the API helper and pass results to render functions:

function loadIndex() { api('/stories') .then(function(stories) { renderList(stories); }) .catch(function(err) { showError(err.message); }); } function loadShow(id) { api('/stories/' + id) .then(function(story) { renderDetail(story); }) .catch(function(err) { showError(err.message); }); }

4. Event Interception

The script intercepts clicks on links and form submissions from the server-rendered HTML. This is the "progressive" part — the server HTML works without JavaScript, and enhance.js makes it smoother when JavaScript is available:

document.addEventListener('click', function(e) { var link = e.target.closest('a'); if (!link) return; var href = link.getAttribute('href'); // Intercept story links var showMatch = href.match(/^\/stories\/(\d+)$/); if (showMatch) { e.preventDefault(); // Stop normal navigation loadShow(showMatch[1]); // Fetch JSON + render } }); document.addEventListener('submit', function(e) { var form = e.target; var action = form.getAttribute('action'); // Intercept delete forms var deleteMatch = action.match(/^\/stories\/(\d+)\/delete$/); if (deleteMatch) { e.preventDefault(); // Stop form submission api('/stories/' + deleteMatch[1], { method: 'DELETE' }) .then(function() { loadIndex(); }); } });

The App Entry Point

The app.js file is based on Module 07 with two key changes:

// Parse BOTH form data (HTML forms) AND JSON (fetch clients) app.use(express.urlencoded({ extended: true })); app.use(express.json()); // Serve static files (enhance.js) app.use(express.static(path.join(__dirname, 'public')));

Module 07 only used express.urlencoded() because it only handled form submissions. Module 09 adds express.json() so req.body works with JSON payloads from fetch() too. And express.static() serves the enhance.js file from the public/ directory.

Running the Demo

1. Install dependencies:

cd node-tutorial/09-adaptable-mvc npm install

2. Start the server:

node app.js

You should see:

Connected to PostgreSQL
Stories app running at http://localhost:3009/stories

3. Test with JavaScript enabled (default): Open http://localhost:3009/stories. Click stories, create, edit, delete. Notice that the page never reloads — enhance.js is intercepting everything and using fetch().

4. Test with JavaScript disabled: Open DevTools → Settings → Debugger → "Disable JavaScript". Reload. Click around. Everything still works, but now every action causes a full page reload. This is the Module 07 experience.

5. Test content negotiation via curl:

# Default request → HTML curl http://localhost:3009/stories # Returns full HTML page # JSON request → JSON curl -H "Accept: application/json" http://localhost:3009/stories # Returns JSON array

6. Open the Network tab in DevTools with JavaScript enabled. You'll see fetch requests with Accept: application/json headers. The responses are JSON, not HTML. Toggle JavaScript off and reload — now all requests are standard HTML navigation.

Three-Way Comparison

Aspect 07: Server-Side 08: Client-Side 09: Adaptable
Server response HTML always JSON always HTML or JSON
Rendering Server (EJS) Browser (JS DOM) Both (depends on client)
First paint Fast (server HTML) Delayed (JS must load + fetch) Fast (server HTML, then JS enhances)
Subsequent actions Full page reload DOM swap (no reload) DOM swap when JS available
Works without JS? Yes No Yes
HTTP methods GET + POST GET, POST, PUT, DELETE All (forms use POST, fetch uses PUT/DELETE)
Body parsers urlencoded() json() Both
Model changes None (same as Module 07)
View changes All new (JS rendering) Layout only (add wrapper + script)
Controller changes All new (JS controller) Add wantsJSON() branching
XSS prevention EJS <%= %> textContent Both (EJS for HTML, textContent for JS)

When to Use Each Pattern

Server-Side MVC (Module 07) — Content-heavy sites where SEO and accessibility matter. Works everywhere. Simple to reason about.

Client-Side MVC (Module 08) — Highly interactive apps (dashboards, email clients, chat). Smooth feel with no reloads. Requires JavaScript.

Adaptable MVC (Module 09) — The best of both worlds when you need it. More controller complexity, but maximum compatibility. Ideal when you want a fast first load with progressive enhancement.