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. ๐ฌ
Play the finished app
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
MVC keeps things tidy by giving every piece one job. In this project each part is literally its own folder:
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
npm init -y
npm install express pug
That's all you need: express (the framework) and pug (the template engine).
Configure Express โ 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
[
{ "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.
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.
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.
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 + Pugextends 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}/10extends 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="/") โ Cancelextends 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 listThe 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
# 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.