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:
- Model — handles all data access (SQL queries against PostgreSQL)
- View — renders HTML using the EJS template engine
- Controller — connects models and views, handles request logic
Prerequisites
- Node.js installed (v16 or later) — nodejs.org
- PostgreSQL running with the
stories_demodatabase from Module 06 - Familiarity with the database patterns from Module 06
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) { %> |
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 (, nav, footer)
└── stories/
├── index.ejs # List all stories
├── show.ejs # Single story detail
└── form.ejs # Create/edit form (dual-purpose)
- Models — know how to talk to the database. They have no knowledge of HTTP, HTML, or requests.
- Views — know how to render HTML. They receive data and produce markup. They have no knowledge of the database.
- Controllers — sit between models and views. They read the request, call the model to get or save data, then pass that data to a view.
- Routes — map URL patterns to controller methods. They contain no logic of their own.
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.
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.
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:
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:
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:
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:
Stories
<% if (stories.length === 0) { %>No stories yet. Create one.
<% } else { %>| Title | Priority | Status | Actions |
|---|---|---|---|
| <%= story.title %> | <%= story.priority %> | <%= story.status %> | Edit |
Key points:
<%= story.title %>auto-escapes the title. If someone created a story called<script>alert('xss')</script>, it would render as harmless text, not execute as JavaScript.- The delete button is wrapped in a tiny
<form>that POSTs to/stories/:id/delete. This is how we handle deletion without JavaScript or PUT/DELETE methods. - CSS class names like
badge-highandbadge-todoare generated dynamically from the data.
Single Story (views/stories/show.ejs)
<%= story.title %>
<%= story.priority %> priority <%= story.status %>
<% if (story.description) { %><%= story.description %>
<% } %>Created: <%= new Date(story.created_at).toLocaleString() %>
Edit Back to listCreate/Edit Form (views/stories/form.ejs)
A single template handles both create and edit. The isEdit flag controls the form action and button label:
<%= isEdit ? 'Edit Story' : 'New Story' %>
<% if (error) { %>This dual-purpose pattern is common in MVC applications. The isEdit flag changes three things:
- The heading ("New Story" vs "Edit Story")
- The form action (
/storiesvs/stories/:id) - The submit button label ("Create Story" vs "Update Story")
XSS Prevention
EJS's <%= %> tag automatically escapes HTML characters. If someone creates a story with the title:
EJS renders it as:
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:
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, then redirect |
| POST | /stories/:id | update | Handle edit, then redirect |
| POST | /stories/:id/delete | destroy | Handle delete, then redirect |
Wiring It Together (app.js)
The entry point creates the database pool, instantiates the model and controller, and connects everything to Express:
Key things to notice:
express.urlencoded({ extended: true })— parses HTML form submissions. In Module 06 we usedexpress.json()because the API accepted JSON. Here the browser sends form data inapplication/x-www-form-urlencodedformat.app.set('view engine', 'ejs')— tells Express to use EJS for rendering. When a controller callsres.render('stories/index', { stories }), Express looks forviews/stories/index.ejs.- Dependency injection chain:
pool→Storymodel →StoryController→ routes. Each layer receives its dependencies from the outside, making the code modular and testable.
Running and Testing
Step by step:
1. Install dependencies:
2. Start the server:
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.
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() |