In this lesson, we are going to create a blog application using Express.js and the MVC architecture.
Creating a new Express project
Let's start by creating a new Express project. Create a new work directory using the following command:
1mkdir blog
1cd blog
Initialize a new Node.js project:
1npm init
A new package.json
file should be created. Add "type": "module"
to it so that the packages can be imported as ES modules.
1{
2 "name": "blog",
3 "type": "module",
4 "version": "1.0.0",
5 "description": "",
6 "main": "index.js",
7 "scripts": {
8 "test": "echo \"Error: no test specified\" && exit 1"
9 },
10 "author": "",
11 "license": "ISC",
12 "dependencies": {
13 "express": "^4.19.2",
14 "pug": "^3.0.2",
15 "sqlite3": "^5.1.7"
16 }
17}
Install packages express
, sqlite3
, pug
, and multer
:
1npm install express sqlite3 pug
In case you forgot what these packages are for, sqlite3
is used to connect to our database, pug
is the template engine, and multer
is used to process forms in multipart/form-data
encoding.
And finally, since we are going to create an MVC architecture, make sure you have a file structure like this:
1.
2├── controllers
3├── database.sqlite
4├── index.js
5├── libs
6├── models
7├── package-lock.json
8├── package.json
9├── routes
10├── uploads
11└── views
Creating routes
First of all, let's start with the routes. This will help us get a general picture of how our project should look like.
index.js
1import express from "express";
2import postRouter from "./routes/post.js";
3import postController from "./controllers/postController.js"; // We will create this later
4
5const app = express();
6const port = 3001;
7
8app.set("views", "./views");
9app.set("view engine", "pug");
10
11app.use(express.json()); // for parsing application/json
12app.use(express.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded
13
14app.get("/", postController.list); // postController is not created yet
15app.use("/posts", postRouter);
16
17app.listen(port, () => {
18 console.log(
19 `Blog application listening on port ${port}. Visit http://localhost:${port}.`
20 );
21});
Line 15, in this example, we are assuming our application will scale in the future, meaning we will be adding more features. So it is best to organize the routes by splitting them into different files.
In this case, the routes starting with /posts
will be defined inside the routes/post.js
file.
routes/post.js
1import { Router } from "express";
2import postController from "../controllers/postController.js"; // We will create this later
3
4const postRouter = Router();
5
6postRouter
7 .route("/new")
8 .get(postController.new) // Display the form to create a new post
9 .post(postController.create); // Create the post
10
11postRouter
12 .route("/edit/:id")
13 .get(postController.edit) // Display the form to edit the post
14 .put(postController.update) // Update the post
15 .delete(postController.delete); // Delete the post
16
17postRouter.route("/:id").get(postController.show); // Display the post
18
19export default postRouter;
Three different routes are defined here:
/posts/new
When a GET
request is received, a form that allows the user to create a new post will be rendered. That form should submit the post by sending a POST
request to /posts/new
.
/posts/edit/:id
When a GET
request is received, a form that allows the user to edit the requested post should be returned. That form should submit the edited post by sending a PUT
request to this route. To delete that post, send a DELETE
request to this route.
/:id
This route only accepts one GET
request. It is used to display the content of the requested post.
Creating a new post
Next, let's think about how to create a new post. The first thing you need is a database table.
The model
Again, this time, you should assume that our app needs to scale in the future, which means there will be multiple models that share the same database connection. So let's create a db.js
file under the libs
directory.
libs/db.js
1import sqlite3 from "sqlite3";
2
3const db = new sqlite3.Database("./database.sqlite");
4
5export default db;
And then import it into our post model.
models/post.js
1import db from "../libs/db.js";
2
3db.serialize(() => {
4 db.run(
5 `CREATE TABLE IF NOT EXISTS posts (
6 id INTEGER PRIMARY KEY AUTOINCREMENT,
7 title TEXT,
8 content TEXT,
9 picture TEXT
10 )`
11 );
12});
13
14class Post {
15 constructor(id, title, content, picture) {
16 this.id = id;
17 this.title = title;
18 this.content = content;
19 this.picture = picture;
20 }
21
22 static create(title, content, picture, callback) {
23 const sql = "INSERT INTO posts (title, content, picture) VALUES (?, ?, ?)";
24 db.run(sql, [title, content, picture.path], function (err) {
25 callback(null, this.lastID);
26 });
27 }
28}
29
30export default Post;
The rest of this example should look very familiar to you. It is almost the same as the user model example from our previous lessons.
Line 3 to 12, the run()
method inside serialize()
creates a new posts
table with three columns: title
, content
, and picture
.
Line 22 to 27, the create()
method is in charge of creating a new post item inside the posts
table.
Add multer
Next, since we are going to need the multipart/form-data
encoding, make sure you integrate the multer
package by adding it as a middleware.
1import { Router } from "express";
2import postController from "../controllers/postController.js";
3import multer from "multer";
4
5const storage = multer.diskStorage({
6 destination: function (req, file, callback) {
7 callback(null, "uploads/");
8 },
9 filename: function (req, file, callback) {
10 callback(null, Date.now() + "-" + file.originalname);
11 },
12});
13
14const upload = multer({ storage: storage });
15
16const postRouter = Router();
17
18postRouter
19 .route("/new")
20 .get(postController.new)
21 .post(upload.single("picture"), postController.create); // Add multer as the middleware
22
23postRouter
24 .route("/edit/:id")
25 .get(postController.edit)
26 .put(postController.update)
27 .delete(postController.delete);
28
29postRouter.route("/:id").get(postController.show);
30
31export default postRouter;
The controller
As for the controllers, we are going to need two controller methods in order to create a new post. For this blog project, we'll create controller methods that are very similar to our previous examples, with only a more standard naming style, as shown in the demo below.
controllers/postController.js
1import Post from "../models/post.js";
2
3const postController = {
4 new: async function (req, res) {
5 res.render("post/new");
6 },
7
8 create: async function (req, res) {
9 const { title, content } = req.body;
10 const picture = req.file;
11
12 Post.create(title, content, picture, (err, postID) => {
13 res.redirect(`/posts/${postID}`);
14 });
15 },
16};
17
18export default postController;
new()
is in charge of displaying the form that allows the user to create a new post, and upon submission, this form should send a POST
request to the backend, and this request will be processed by the create()
method.
create()
will then call the Post.create()
method we just discussed in the post.js
model, which will add the new post to the database.
Notice that in line 5, we are rendering the new.pug
template under the post
directory. Yes, it is possible to create a nested file structure for our views.
The views
This is our template structure:
1views
2├── index.pug
3├── layout.pug
4└── post
5 ├── edit.pug
6 ├── new.pug
7 └── show.pug
index.pug
will be rendered into the homepage, and in this case, it will display a list of all posts for our blog. layout.pug
is the layout, and all other templates should extend to the layout.pug
.
All the post-related templates are placed inside the post
directory. edit.pug
contains a form to edit a post, new.pug
contains a form to create a new post, and finally, show.pug
displays the actual post.
Let's start with the layout.pug
. To make our demos easier to understand, we stripped all the unnecessary styling and content.
views/layout.pug
1doctype html
2html
3 head
4 block meta
5
6 body
7 nav
8 a(href="/posts/new") New Post
9 a(href="/") Home
10 block content
11 footer
And then, make sure our new.pug
extends to layout.pug
.
The new.pug
template contains the exact same form we discussed before in the previous lesson, with only slightly different names.
views/post/new.pug
1extends ../layout.pug
2
3block meta
4 title Create New Post
5
6block content
7 form(id="newPost")
8 label(for="title") Title:
9 input(type="text", name="title", id="title")
10
11 label(for="content") Content:
12 textarea(name="content", cols="30", rows="10", id="content")
13
14 label(for="picture")
15 input(type="file", name="picture", id="picture")
16
17 input(type="submit", value="Submit")
18
19 script.
20 . . .
Since we are uploading an image, make sure you are using FormData()
.
1document.addEventListener("DOMContentLoaded", function () {
2 const form = document.getElementById("newPost");
3 const titleInput = document.getElementById("title");
4 const contentInput = document.getElementById("content");
5 const pictureInput = document.getElementById("picture");
6
7 const formData = new FormData();
8
9 titleInput.addEventListener("input", function () {
10 formData.set("title", titleInput.value);
11 });
12
13 contentInput.addEventListener("input", function () {
14 formData.set("content", contentInput.value);
15 });
16
17 pictureInput.addEventListener("change", function () {
18 formData.set("picture", pictureInput.files[0]);
19 });
20
21 form.addEventListener("submit", async function (event) {
22 event.preventDefault(); // Prevent the default form submission
23
24 await fetch("/posts/new", {
25 method: "POST",
26 body: formData,
27 });
28 });
29});
Displaying a list of posts
index.js
1app.get("/", postController.list);
controllers/postController.js
1import Post from "../models/post.js";
2
3const postController = {
4 list: async function (req, res) {
5 Post.getAll((err, posts) => {
6 res.render("index", {
7 posts,
8 });
9 });
10 },
11 . . .
12};
13
14export default postController;
models/post.js
1class Post {
2 constructor(id, title, content, picture) {
3 this.id = id;
4 this.title = title;
5 this.content = content;
6 this.picture = picture;
7 }
8
9 // Fetch all posts from the database
10 static getAll(callback) {
11 const sql = "SELECT * FROM posts";
12 db.all(sql, (err, rows) => {
13 const posts = rows.map(
14 (row) => new Post(row.id, row.title, row.content, row.picture)
15 );
16 callback(null, posts);
17 });
18 }
19
20 . . .
21}
22
23export default Post;
views/index.pug
1extends ./layout.pug
2
3block meta
4 title My New Blog
5
6block content
7 ul
8 each post in posts
9 li
10 a(href=`/posts/${post.id}`) #{post.title}
11 else
12 li No post found.
Displaying a single post
routes/post.js
1postRouter.route("/:id").get(postController.show);
controllers/postController.js
1import Post from "../models/post.js";
2
3const postController = {
4 show: async function (req, res) {
5 const id = req.params.id;
6 Post.getById(id, (err, post) => {
7 res.render("post/show", {
8 post,
9 });
10 });
11 },
12
13 . . .
14
15};
16
17export default postController;
models/post.js
1import db from "../libs/db.js";
2
3db.serialize(() => {
4 db.run(
5 `CREATE TABLE IF NOT EXISTS posts (
6 id INTEGER PRIMARY KEY AUTOINCREMENT,
7 title TEXT,
8 content TEXT,
9 picture TEXT
10 )`
11 );
12});
13
14class Post {
15 constructor(id, title, content, picture) {
16 this.id = id;
17 this.title = title;
18 this.content = content;
19 this.picture = picture;
20 }
21
22 // Fetch a post by ID from the database
23 static getById(id, callback) {
24 const sql = "SELECT * FROM posts WHERE id = ?";
25 db.get(sql, [id], (err, row) => {
26 const post = new Post(row.id, row.title, row.content, row.picture);
27 callback(null, post);
28 });
29 }
30
31 . . .
32
33}
34
35export default Post;
views/post/show.pug
1extends ../layout.pug
2
3block meta
4 title #{post.title}
5
6block content
7 h1 #{post.title}
8 div #{post.content}
9
10 a(href=`/posts/edit/${post.id}`) Edit this post
Updating a post
routes/post.js
1postRouter
2 .route("/edit/:id")
3 .get(postController.edit)
4 .put(upload.single("picture"), postController.update)
5 .delete(postController.delete);
controllers/postController.js
1import Post from "../models/post.js";
2
3const postController = {
4 edit: async function (req, res) {
5 const id = req.params.id;
6 Post.getById(id, (err, post) => {
7 res.render("post/edit", {
8 post,
9 });
10 });
11 },
12
13 update: async function (req, res) {
14 const { title, content } = req.body;
15 const picture = req.file;
16
17 Post.update(req.params.id, title, content, picture, (err, postID) => {
18 res.redirect(`/posts/${postID}`);
19 });
20 },
21
22 . . .
23
24};
25
26export default postController;
When it comes to updating a post, we have to consider two scenarios, when a new picture is submitted, and when no pictures are submitted. These two conditions require two different SQL queries, as shown in the example below.
models/post.js
1class Post {
2 constructor(id, title, content, picture) {
3 this.id = id;
4 this.title = title;
5 this.content = content;
6 this.picture = picture;
7 }
8
9 // Update an existing post in the database
10 static update(id, title, content, picture, callback) {
11 if (picture) {
12 const sql =
13 "UPDATE posts SET title = ?, content = ?, picture = ? WHERE id = ?";
14 db.run(sql, [title, content, picture.path, id], function (err) {
15 callback(null, this.lastID);
16 });
17 } else {
18 const sql = "UPDATE posts SET title = ?, content = ? WHERE id = ?";
19 db.run(sql, [title, content, id], function (err) {
20 callback(null, this.lastID);
21 });
22 }
23 }
24
25 . . .
26}
27
28export default Post;
views/post/edit.pug
1extends ../layout.pug
2
3block meta
4 title Update Post
5
6block content
7 form(id="editPost")
8 label(for="title") Title:
9 input(type="text", name="title", id="title", value=`${post.title}`)
10
11 label(for="content") Content:
12 textarea(name="content", cols="30", rows="10", id="content") #{post.content}
13
14 label(for="picture")
15 input(type="file", name="picture", id="picture")
16
17 input(type="submit", value="Submit")
18
19 form(id="deletePost")
20 input(type="submit", value="Delete Post")
21
22 script.
23 . . .
1const postID = JSON.parse("!{JSON.stringify(post.id)}");
2
3document.addEventListener("DOMContentLoaded", function () {
4 const form = document.getElementById("editPost");
5 const titleInput = document.getElementById("title");
6 const contentInput = document.getElementById("content");
7 const pictureInput = document.getElementById("picture");
8
9 const formData = new FormData();
10 formData.set("title", titleInput.value);
11 formData.set("content", contentInput.value);
12 formData.set("picture", pictureInput.files[0]);
13
14 titleInput.addEventListener("input", function () {
15 formData.set("title", titleInput.value);
16 });
17
18 contentInput.addEventListener("input", function () {
19 formData.set("content", contentInput.value);
20 });
21
22 pictureInput.addEventListener("change", function () {
23 formData.set("picture", pictureInput.files[0]);
24 });
25
26 form.addEventListener("submit", async function (event) {
27 event.preventDefault(); // Prevent the default form submission
28
29 await fetch(`/posts/edit/${postID}`, {
30 method: "PUT",
31 body: formData,
32 });
33 });
34});
When no new picture is selected, the picture
key in formData
will be assigned undefined
.
Deleting a post
routes/post.js
1postRouter
2 .route("/edit/:id")
3 .get(postController.edit)
4 .put(upload.single("picture"), postController.update)
5 .delete(postController.delete);
controllers/postController.js
1import Post from "../models/post.js";
2
3const postController = {
4 delete: async function (req, res) {
5 console.log(req.params.id);
6 Post.delete(req.params.id, (err) => {
7 res.redirect("/");
8 });
9 },
10
11 . . .
12
13};
14
15export default postController;
models/post.js
1class Post {
2 constructor(id, title, content, picture) {
3 this.id = id;
4 this.title = title;
5 this.content = content;
6 this.picture = picture;
7 }
8
9 // Delete a post from the database
10 static delete(id, callback) {
11 const sql = "DELETE FROM posts WHERE id = ?";
12 db.run(sql, [id], function (err) {
13 callback(null);
14 });
15 }
16
17 . . .
18
19}
20
21export default Post;
views/post/edit.pug
1extends ../layout.pug
2
3block meta
4 title Update Post
5
6block content
7 form(id="editPost")
8 label(for="title") Title:
9 input(type="text", name="title", id="title", value=`${post.title}`)
10
11 label(for="content") Content:
12 textarea(name="content", cols="30", rows="10", id="content") #{post.content}
13
14 label(for="picture")
15 input(type="file", name="picture", id="picture")
16
17 input(type="submit", value="Submit")
18
19 form(id="deletePost")
20 input(type="submit", value="Delete Post")
21
22 script.
23 . . .
1const postID = JSON.parse("!{JSON.stringify(post.id)}");
2
3document.addEventListener("DOMContentLoaded", function () {
4 . . .
5
6 const deleteForm = document.getElementById("deletePost");
7
8 deleteForm.addEventListener("submit", async function (event) {
9 event.preventDefault(); // Prevent the default form submission
10
11 await fetch(`/posts/edit/${postID}`, {
12 method: "DELETE",
13 headers: {
14 "Content-Type": "application/x-www-form-urlencoded",
15 },
16 });
17 });
18});
Fixing redirects
Before we finish this lesson, there is one more thing we need to do. You may have noticed that we created a few redirects in our post controller. For example,
controllers/postController.js
1import Post from "../models/post.js";
2
3const postController = {
4 create: async function (req, res) {
5 const { title, content } = req.body;
6 const picture = req.file;
7
8 Post.create(title, content, picture, (err, postID) => {
9 res.redirect(`/posts/${postID}`); // <===
10 });
11 },
12
13 . . .
14
15};
16
17export default postController;
In this case, when a new post is created, we are supposed to be redirected to the show
post page. But you probably have noticed that it is not really working.
That is because res.redirect()
only sends a redirect response back to the browser. By default, the browser doesn't know what to do with it, so you still need to handle this response in the frontend. To do this, you must chain a then()
method to our fetch()
.
1await fetch("/posts/new", {
2 method: "POST",
3 body: formData,
4}).then((res) => (window.location.href = res.url));
The response will be transferred to that then()
method, and you can use window.location.href
to redirect the user to different pages under the browser environment.
And now, the redirects should work correctly.