GUIDED PROJECT · MVC

The Anime Collection app

A complete little MVC web app โ€” Express + Pug + forms + full CRUD. Play the finished version below (it really works!), then build it yourself, one file at a time. ๐ŸŽฌ

Uses Express Router Pug views + layout forms & req.body list ยท view ยท add ยท delete

Play the finished app

This is the real app running right here โ€” click a title, add one, delete one. The data lives in memory (Reset to restore).
๐Ÿ”’ localhost:3000/

Every click is a real request routed to a controller, which renders a Pug view โ€” exactly the MVC flow you'll build below.

The MVC idea, in folders

Model = the data ยท View = the Pug pages ยท Controller = the functions that connect them.

MVC keeps things tidy by giving every piece one job. In this project each part is literally its own folder:

anime-collection/ โ”œโ”€โ”€ app.js # wiring & config โ”œโ”€โ”€ package.json โ”‚ โ”œโ”€โ”€ data/ โ”‚ โ””โ”€โ”€ anime.json โ† MODEL (the data) โ”‚ โ”œโ”€โ”€ controllers/ โ”‚ โ””โ”€โ”€ animeController.js โ† CONTROLLER โ”‚ โ”œโ”€โ”€ routes/ โ”‚ โ””โ”€โ”€ anime.js # URL โ†’ controller โ”‚ โ”œโ”€โ”€ views/ โ† VIEW (Pug templates) โ”‚ โ”œโ”€โ”€ layout.pug โ”‚ โ”œโ”€โ”€ index.pug โ”‚ โ”œโ”€โ”€ add.pug โ”‚ โ””โ”€โ”€ details.pug โ”‚ โ””โ”€โ”€ public/ โ””โ”€โ”€ css/ โ””โ”€โ”€ style.css

The flow of one click: browser โ†’ route (which URL?) โ†’ controller (do the work) โ†’ model (get/change data) โ†’ view (render Pug) โ†’ HTML back to the browser.

Build it in stages

Don't write everything at once. Follow the stages โ€” each file is short.

Install the packages

terminal
npm init -y
npm install express pug

That's all you need: express (the framework) and pug (the template engine).

Configure Express โ€” app.js

app.js
const express = require("express");
const path = require("path");
const app = express();

// Parse form data (so req.body works on POSTed forms)
app.use(express.urlencoded({ extended: true }));

// Tell Express to use Pug as the template engine
app.set("view engine", "pug");

// Tell Express where the templates (views) live
app.set("views", path.join(__dirname, "views"));

// Serve static files (CSS, images) from /public
app.use(express.static(path.join(__dirname, "public")));

// Mount the anime routes at the root path
const animeRoutes = require("./routes/anime");
app.use("/", animeRoutes);

app.listen(3000, () => {
    console.log("Server running on http://localhost:3000");
});

The two lines that make Pug work: app.set("view engine", "pug") ("my templates are Pug") and app.set("views", ...) ("they live in the views folder"). express.urlencoded() is what fills req.body from a submitted form.

The MODEL โ€” data/anime.json

data/anime.json
[
    { "id": 1, "title": "Fullmetal Alchemist: Brotherhood", "rating": 10 },
    { "id": 2, "title": "Attack on Titan", "rating": 9 },
    { "id": 3, "title": "Frieren", "rating": 10 }
]

No database yet โ€” just a JSON file. The controller reads it into memory when the server starts.

The CONTROLLER โ€” controllers/animeController.js

The brains. Each function handles one job: read the list, show one, show the add form, create, delete.

controllers/animeController.js
const fs = require("fs");
const path = require("path");

const dataPath = path.join(__dirname, "..", "data", "anime.json");

// MODEL: read the JSON file into memory once, when the server starts
let animeList = JSON.parse(fs.readFileSync(dataPath, "utf8"));
let nextId = Math.max(0, ...animeList.map(a => a.id)) + 1;

// GET /  โ†’ show the whole collection
exports.index = (req, res) => {
    res.render("index", { title: "My Anime Collection", anime: animeList });
};

// GET /anime/:id  โ†’ show one anime's details
exports.details = (req, res) => {
    const anime = animeList.find(a => a.id === Number(req.params.id));
    if (!anime) return res.status(404).render("index", { title: "Not found", anime: animeList });
    res.render("details", { title: anime.title, anime });
};

// GET /add  โ†’ show the form
exports.addForm = (req, res) => {
    res.render("add", { title: "Add anime" });
};

// POST /add  โ†’ create, then go back to the list
exports.create = (req, res) => {
    const anime = { id: nextId++, title: req.body.title, rating: Number(req.body.rating) };
    animeList.push(anime);
    res.redirect("/");
};

// POST /anime/:id/delete  โ†’ remove, then go back
exports.remove = (req, res) => {
    animeList = animeList.filter(a => a.id !== Number(req.params.id));
    res.redirect("/");
};

Stretch goal: right now changes live in memory and reset when the server restarts. Add a fs.writeFileSync(dataPath, JSON.stringify(animeList, null, 2)) after each change to save back to the file โ€” real persistence!

The ROUTES โ€” routes/anime.js

The router just maps a URL + method to a controller function. No logic here.

routes/anime.js
const express = require("express");
const router = express.Router();
const animeController = require("../controllers/animeController");

router.get("/", animeController.index);                    // list all
router.get("/add", animeController.addForm);               // show add form
router.post("/add", animeController.create);               // handle add form
router.get("/anime/:id", animeController.details);         // show one
router.post("/anime/:id/delete", animeController.remove);  // delete one

module.exports = router;

The VIEWS โ€” Pug templates

layout.pug is the shared shell; every page extends it and fills the content block.

views/layout.pug
layout.pug
doctype html
html(lang="en")
  head
    title #{title} โ€” Anime Collection
    link(rel="stylesheet" href="/css/style.css")
  body
    header.site-header
      a.brand(href="/") ๐ŸŽฌ Anime Collection
      a.btn(href="/add") + Add anime
    main.container
      block content
    footer.site-footer
      p Built with Express + Pug
views/index.pug
index.pug
extends layout

block content
  h1= title
  if anime.length === 0
    p.empty No anime yet!
  else
    ul.anime-list
      each item in anime
        li.anime-card
          a.anime-title(href=`/anime/${item.id}`)= item.title
          span.rating โ˜… #{item.rating}/10
views/add.pug
add.pug
extends layout

block content
  h1 Add a new anime
  form.form(action="/add" method="POST")
    label(for="title") Title
    input(type="text" name="title" required)
    label(for="rating") Rating (1โ€“10)
    input(type="number" name="rating" min="1" max="10" required)
    button.btn(type="submit") Save
  a.back(href="/") โ† Cancel
views/details.pug
details.pug
extends layout

block content
  h1= anime.title
  p.rating-big โ˜… #{anime.rating}/10
  form(action=`/anime/${anime.id}/delete` method="POST")
    button.btn.danger(type="submit") Delete
  a.back(href="/") โ† Back to list

The style โ€” public/css/style.css

Because of express.static("public"), the file at public/css/style.css is served at /css/style.css โ€” exactly what layout.pug links to. Download style.css to see the full theme.

Run it on your own machine

terminal
# 1. put the files together (or download them from the tree above)
# 2. install the packages
npm install
# 3. start the server
node app.js
# 4. open http://localhost:3000 ๐ŸŽ‰

Grab the files: every filename in the folder tree above is a download link. Recreate the structure, run the commands, and you've got the same app you just played with.

Make it yours (challenges)

  • Edit โ€” add an edit.pug + a PUT/POST route to change a rating.
  • Persist โ€” write changes back to anime.json with fs.writeFileSync.
  • Validate โ€” reject empty titles or ratings outside 1โ€“10 in the controller.
  • Sort โ€” show the list ordered by rating (highest first).
  • Style โ€” add a poster image field and show it on the details page.