09. Adaptable MVC

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:

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

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 app.jshttp://localhost:3009/stories

Live demo (PHP version): Try the Adaptable MVC demo

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'
    }
});
Why not check for AJAX or X-Requested-With? Content negotiation via the Accept header is the standard HTTP mechanism for this. It works with any client (not just browsers), is cacheable, and doesn’t require custom headers.

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');
}
The Model is unchanged. models/Story.js is identical to Module 07. The Model doesn’t know or care whether the controller will respond with HTML or JSON. This is MVC separation of concerns in action.

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:

<!-- layout.ejs changes from Module 07 -->
<div id="content">
    <%- body %>
</div>

<!-- Before </body> -->
<script src="/enhance.js"></script>

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(); });
    }
});
First paint is instant. On the initial page load, the server renders full HTML. There’s no “Loading...” spinner. The page is immediately usable. enhance.js then quietly attaches event listeners. All subsequent interactions are enhanced with fetch() + DOM updates — no page reloads.

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.

XSS test: Create a story with the title <script>alert('xss')</script>. It’s safe in both paths: server-rendered HTML uses EJS <%= %> auto-escaping, and the JavaScript rendering uses textContent.

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.

Real-world examples: GitHub uses progressive enhancement — the site works without JavaScript, but JS enhances the experience (inline editing, live updates). Many modern frameworks (Next.js, Remix, Nuxt) build on this same principle of server rendering with client-side hydration.