Aside from building APIs, Node.js is great for building standard web applications. It has powerful tools to meet the needs of web developers. In this tutorial, you will be building a web application that can serve as a local library.
While building, you will learn about some types of middleware, you will see how to handle form submission in Node.js, and you will also be able to reference two models.
Let's get started.
Getting Started
Start by installing the express generator on your machine.
1 |
npm install express-generator -g |
Run the express generator command to generate your application.
1 |
express tutsplus-library --view=pug |
1 |
create : nodejs-webapp/ |
2 |
create : nodejs-webapp/public/ |
3 |
create : nodejs-webapp/public/javascripts/ |
4 |
create : nodejs-webapp/public/images/ |
5 |
create : nodejs-webapp/public/stylesheets/ |
6 |
create : nodejs-webapp/public/stylesheets/style.css |
7 |
create : nodejs-webapp/routes/ |
8 |
create : nodejs-webapp/routes/index.js |
9 |
create : nodejs-webapp/routes/users.js |
10 |
create : nodejs-webapp/views/ |
11 |
create : nodejs-webapp/views/error.pug |
12 |
create : nodejs-webapp/views/index.pug |
13 |
create : nodejs-webapp/views/layout.pug |
14 |
create : nodejs-webapp/app.js |
15 |
create : nodejs-webapp/package.json |
16 |
create : nodejs-webapp/bin/ |
17 |
create : nodejs-webapp/bin/www |
18 |
|
19 |
change directory: |
20 |
$ cd nodejs-webapp |
21 |
|
22 |
install dependencies:
|
23 |
$ npm install |
24 |
|
25 |
run the app:
|
26 |
$ DEBUG=nodejs-webapp:* npm start |
Now migrate into your working folder, open package.json, and make the dependencies similar to what I have below.
1 |
{
|
2 |
"name": "nodejs-webapp", |
3 |
"version": "0.0.0", |
4 |
"private": true, |
5 |
"scripts": { |
6 |
"start": "node ./bin/www" |
7 |
},
|
8 |
"dependencies": { |
9 |
"body-parser": "^1.20.0", |
10 |
"connect-flash": "^0.1.1", |
11 |
"cookie-parser": "~1.4.4", |
12 |
"debug": "~2.6.9", |
13 |
"express": "~4.16.1", |
14 |
"express-messages": "^1.0.1", |
15 |
"express-session": "^1.17.3", |
16 |
"express-validator": "^6.14.1", |
17 |
"http-errors": "~1.6.3", |
18 |
"mongoose": "^6.3.8", |
19 |
"morgan": "~1.9.1", |
20 |
"pug": "^2.0.0-beta11", |
21 |
"serve-favicon": "^2.5.0" |
22 |
},
|
23 |
"type": "module" |
24 |
}
|
Run the command to install the packages.
1 |
npm install
|
Set Up the Entry File
The app.js file was created when you ran the generator command; however, you need to do some extra configuration. Edit the file to look like what I have below. I'll go through the file and explain the changes as we go.
1 |
import express from "express"; |
2 |
import { join } from "path"; |
3 |
import favicon from "serve-favicon"; |
4 |
import logger from "morgan"; |
5 |
import cookieParser from "cookie-parser"; |
6 |
import bodyParser from "body-parser"; |
7 |
import session from "express-session"; |
8 |
import expressValidator from "express-validator"; |
9 |
import flash from "connect-flash"; |
10 |
import mongoose from "mongoose"; |
11 |
import messages from "express-messages"; |
Require the two routes for the app. You will create the routes file shortly. The required routes are assigned as values to two different variables which are used when setting up the middleware for your routes.
1 |
import genres from "./routes/genres"; |
2 |
import books from "./routes/books"; |
3 |
|
4 |
const app = express(); |
Next, set Mongoose to use global.Promise
. The MongoDB
variable is assigned the MONGODB_URI
of your environment or the path to your local mongo server. This variable is passed as an argument to connect to the running MongoDB server.
1 |
const mongoDB = |
2 |
process.env.MONGODB_URI || "mongodb://127.0.0.1/tutsplus-library"; |
3 |
mongoose.connect(mongoDB); |
4 |
|
5 |
// view engine setup
|
6 |
app.set("views", join(__dirname, "views")); |
7 |
app.set("view engine", "pug"); |
8 |
|
9 |
// uncomment after placing your favicon in /public
|
10 |
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
|
11 |
app.use(logger("dev")); |
12 |
app.use(bodyParser.json()); |
13 |
app.use(bodyParser.urlencoded({ extended: false })); |
14 |
app.use(cookieParser()); |
15 |
app.use(express.static(join(__dirname, "public"))); |
Set up session middleware using express-session
. This middleware is important as you will be displaying flash messages in some parts of your application.
1 |
app.use( |
2 |
session({ |
3 |
secret: "secret", |
4 |
saveUninitialized: true, |
5 |
resave: true, |
6 |
})
|
7 |
);
|
Set up middleware for validation. This middleware will be used to validate form input, ensuring that users of the application do not submit an empty form. The validation uses express-validator
.
1 |
app.use( |
2 |
expressValidator({ |
3 |
errorFormatter: function (param, msg, value) { |
4 |
var namespace = param.split("."), |
5 |
root = namespace.shift(), |
6 |
formParam = root; |
7 |
|
8 |
while (namespace.length) { |
9 |
formParam += "[" + namespace.shift() + "]"; |
10 |
}
|
11 |
return { |
12 |
param: formParam, |
13 |
msg: msg, |
14 |
value: value, |
15 |
};
|
16 |
},
|
17 |
})
|
18 |
);
|
Set up middleware which will come in handy when displaying flash messages. This middleware uses connect-flash
.
1 |
app.use(flash()); |
2 |
app.use(function (req, res, next) { |
3 |
res.locals.messages = messages; |
4 |
next(); |
5 |
});
|
The routes for the application are set up to use the routes file you required. Requests pointing to /genres and /books will use the genres and books routes files respectively. At this moment, you have not created the routes files, but you will do that soon.
1 |
app.use("/genres", genres); |
2 |
app.use("/books", books); |
3 |
|
4 |
// catch 404 and forward to error handler
|
5 |
app.use(function (req, res, next) { |
6 |
var err = new Error("Not Found"); |
7 |
err.status = 404; |
8 |
next(err); |
9 |
});
|
10 |
|
11 |
// error handler
|
12 |
app.use(function (err, req, res, next) { |
13 |
// set locals, only providing error in development
|
14 |
res.locals.message = err.message; |
15 |
res.locals.error = req.app.get("env") === "development" ? err : {}; |
16 |
|
17 |
// render the error page
|
18 |
res.status(err.status || 500); |
19 |
res.render("error"); |
20 |
});
|
21 |
|
22 |
export default app; |
Note that this code was converted to ESM, as the generator only generates CommonJS.
What Is ESM and Why Are There import
Statements?
ESM (ECMAScript Modules) is a next-generation module format for JavaScript on the web and Node.js. It replaces CommonJS, an old module format used in Node.js that you likely have seen (it uses require()
and module.exports
). ESM supports new features like asynchronous module loading, tree shaking, and support on the web. Going forward, ESM will slowly replace CommonJS (and already has for many new applications), so in this tutorial, we will be using ESM. There are import
statements because that is how you import packages using ESM.
Another thing you need to do is edit /bin/www
to use ESM.
1 |
#!/usr/bin/env node
|
2 |
|
3 |
// bin/www
|
4 |
|
5 |
/**
|
6 |
* Module dependencies.
|
7 |
*/
|
8 |
import app from "../app"; |
9 |
import debugLib from "debug"; |
10 |
import { createServer } from "http"; |
11 |
const debug = debugLib("nodejs-webapp:server"); |
12 |
|
13 |
/**
|
14 |
* Get port from environment and store in Express.
|
15 |
*/
|
16 |
|
17 |
const port = normalizePort(process.env.PORT || "3000"); |
18 |
app.set("port", port); |
19 |
|
20 |
/**
|
21 |
* Create HTTP server.
|
22 |
*/
|
23 |
|
24 |
const server = createServer(app); |
25 |
|
26 |
/**
|
27 |
* Listen on provided port, on all network interfaces.
|
28 |
*/
|
29 |
|
30 |
server.listen(port); |
31 |
server.on("error", onError); |
32 |
server.on("listening", onListening); |
33 |
|
34 |
/**
|
35 |
* Normalize a port into a number, string, or false.
|
36 |
*/
|
37 |
|
38 |
function normalizePort(val) { |
39 |
const port = parseInt(val, 10); |
40 |
|
41 |
if (isNaN(port)) { |
42 |
// named pipe
|
43 |
return val; |
44 |
}
|
45 |
|
46 |
if (port >= 0) { |
47 |
// port number
|
48 |
return port; |
49 |
}
|
50 |
|
51 |
return false; |
52 |
}
|
53 |
|
54 |
/**
|
55 |
* Event listener for HTTP server "error" event.
|
56 |
*/
|
57 |
|
58 |
function onError(error) { |
59 |
if (error.syscall !== "listen") { |
60 |
throw error; |
61 |
}
|
62 |
|
63 |
const bind = typeof port === "string" ? "Pipe " + port : "Port " + port; |
64 |
|
65 |
// handle specific listen errors with friendly messages
|
66 |
switch (error.code) { |
67 |
case "EACCES": |
68 |
console.error(bind + " requires elevated privileges"); |
69 |
process.exit(1); |
70 |
break; |
71 |
case "EADDRINUSE": |
72 |
console.error(bind + " is already in use"); |
73 |
process.exit(1); |
74 |
break; |
75 |
default: |
76 |
throw error; |
77 |
}
|
78 |
}
|
79 |
|
80 |
/**
|
81 |
* Event listener for HTTP server "listening" event.
|
82 |
*/
|
83 |
|
84 |
function onListening() { |
85 |
const addr = server.address(); |
86 |
const bind = typeof addr === "string" ? "pipe " + addr : "port " + addr.port; |
87 |
debug("Listening on " + bind); |
88 |
}
|
Finally, delete the auto-generated routes files (index.js and user.js).
Book and Genre Model
The Book Model will make use of Mongoose Schema to define how the books will be structured. Create a directory called models and a new file called book.js. Here is what it looks like.
1 |
import mongoose from "mongoose"; |
2 |
|
3 |
const Schema = mongoose.Schema; |
4 |
|
5 |
const bookSchema = Schema({ |
6 |
name: { |
7 |
type: String, |
8 |
trim: true, |
9 |
required: "Please enter a book name", |
10 |
},
|
11 |
description: { |
12 |
type: String, |
13 |
trim: true, |
14 |
},
|
15 |
author: { |
16 |
type: String, |
17 |
trim: true, |
18 |
},
|
19 |
genre: [ |
20 |
{
|
21 |
type: Schema.Types.ObjectId, |
22 |
ref: "Genre", |
23 |
},
|
24 |
],
|
25 |
});
|
26 |
|
27 |
export default mongoose.model("Book", bookSchema); |
Here you have four fields. The last field is used to store the genre each book belongs to. The genre
field here references the Genre
model, which will be created next. That's why the type is set to Schema.Types.ObjectId
, which is where the ids of each referenced genre will be saved. ref
specifies the model you are referencing. Note that the genre is saved as an array, meaning that a book can have more than one genre.
Let's go ahead and create the Genre
model in models/genre.js.
1 |
import mongoose from "mongoose"; |
2 |
|
3 |
const Schema = mongoose.Schema; |
4 |
|
5 |
const genreSchema = Schema({ |
6 |
name: { |
7 |
type: String, |
8 |
trim: true, |
9 |
required: "Please enter a Genre name", |
10 |
},
|
11 |
});
|
12 |
export default mongoose.model("Genre", genreSchema); |
For your Genre, you need just one field: name
.
Genre Index Route and View
For this tutorial, you will make use of two routes paths for your genre: a path to add new genres, and another that lists the genres you have. Create a file in your routes directory called genres.js.
Start by requiring all the modules you will be using.
1 |
import { Router } from "express"; |
2 |
import mongoose from "mongoose"; |
3 |
import genre from "../models/genre"; |
Next, drop in the route that handles the index file for your genres.
1 |
router.get("/", (req, res, next) => { |
2 |
const genres = Genre.find({}) |
3 |
.exec() |
4 |
.then( |
5 |
(genres) => { |
6 |
res.render("genres", { genres: genres }); |
7 |
},
|
8 |
(err) => { |
9 |
throw err; |
10 |
}
|
11 |
);
|
12 |
});
|
This route gets called whenever requests are made to /genres. Here you call the find method on your Genre model to obtain all the genres that have been created. These genres are then rendered on a template called genres. Let's go ahead and create that, but first, update your layout.pug to look like this:
1 |
doctype html |
2 |
html |
3 |
head |
4 |
title= title |
5 |
link(rel='stylesheet', href='/stylesheets/style.css') |
6 |
link(rel='stylesheet', href='https://bootswatch.com/paper/bootstrap.css') |
7 |
script(src='https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js') |
8 |
script(src='https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js') |
9 |
body |
10 |
.container-fluid |
11 |
block header |
12 |
nav.navbar.navbar-inverse |
13 |
.container-fluid |
14 |
.navbar-header |
15 |
button.navbar-toggle.collapsed(type='button', data-toggle='collapse', data-target='#bs-example-navbar-collapse-2') |
16 |
span.sr-only Toggle navigation |
17 |
span.icon-bar |
18 |
span.icon-bar |
19 |
span.icon-bar |
20 |
a.navbar-brand(href='#') Local Library |
21 |
#bs-example-navbar-collapse-2.collapse.navbar-collapse |
22 |
ul.nav.navbar-nav.navbar-right |
23 |
li |
24 |
a(href='/books') View Books |
25 |
li |
26 |
a(href='/books/add') Add New Book |
27 |
li |
28 |
a(href='/genres') View Genres |
29 |
li |
30 |
a(href='/genres/add') Add New Genre |
31 |
block content |
This will give your views a nice structure to aid navigation. Now create a view file called genre.pug. In this file, you will loop through the genres created and output each genre in an unordered list.
Here is how the file should look.
1 |
extends layout |
2 |
|
3 |
block content |
4 |
h1 Genre |
5 |
ul.well.well-lg |
6 |
each genre, i in genres |
7 |
li.well.well-sm |
8 |
p #{genre.name} |
Add New Genre Routes and View
Go back to your routes/genres.js file to add the routes that will handle the creation of new genres.
The job of this router is to simply display the page for adding new routes. This router gets called whenever requests are made to the /genres/add path.
1 |
router.get("/add", (req, res, next) => { |
2 |
res.render("addGenre"); |
3 |
});
|
4 |
This router handles the submission of the form. When the form is submitted, we check to ensure that the user has entered a name. If no name is entered, the page is rendered again. If the checks are good to go, the genre is saved and the user is redirected to the /genres page.
1 |
router.post("/add", (req, res, next) => { |
2 |
req.checkBody("name", "Name is required").notEmpty(); |
3 |
|
4 |
const errors = req.validationErrors(); |
5 |
|
6 |
if (errors) { |
7 |
console.log(errors); |
8 |
res.render("addgenres", { genre, errors }); |
9 |
}
|
10 |
|
11 |
const genre = new Genre(req.body) |
12 |
.save() |
13 |
.then((data) => { |
14 |
res.redirect("/genres"); |
15 |
})
|
16 |
.catch((errors) => { |
17 |
console.log("oops..."); |
18 |
console.log(errors); |
19 |
});
|
20 |
});
|
21 |
The module is exported as a router.
1 |
export default router; |
Now you can go ahead and create the page for adding a new genre to views/addGenre.pug.
1 |
extends layout |
2 |
|
3 |
block content |
4 |
.row |
5 |
.col-md-12 |
6 |
h1 Add Book |
7 |
form(method="POST", action="/genres/add") |
8 |
.form-group |
9 |
label.col-lg-2.control.label Name |
10 |
.col-lg-10 |
11 |
input.form-control(type="text", name='name') |
12 |
.form-group |
13 |
.col-lg-10.col-lg-offset-2 |
14 |
input.button.btn.btn-primary(type='submit', value='Submit') |
15 |
|
16 |
if errors |
17 |
ul |
18 |
for error in errors |
19 |
li!= error.msg |
Books Routes and View
Create a new route file for books, and name it books.js. As you did earlier with the genre, start by requiring the necessary modules.
1 |
import { Router } from "express"; |
2 |
import mongoose from "mongoose"; |
3 |
import Book from "../models/book"; |
Next, set up the router for displaying all the books saved in the library. Try that on your own in the same way you set up that of the genre; you can always check back to make corrections.
I guess you tried that out—here is how it should look.
1 |
router.get("/", (req, res, next) => { |
2 |
const books = Book.find({}) |
3 |
.exec() |
4 |
.then( |
5 |
(books) => { |
6 |
res.render("books", { books: books }); |
7 |
},
|
8 |
(err) => { |
9 |
throw err; |
10 |
}
|
11 |
);
|
12 |
});
|
When this router is called, a request is made to find all books saved in the database. If all goes well, the books are shown on the /books page, else an error is thrown.
You need to create a new file for displaying all books, views/books.pug, and here is how it should look.
1 |
extends layout |
2 |
|
3 |
block content |
4 |
h1 Books |
5 |
ul.well.well-lg |
6 |
each book, i in books |
7 |
li.well.well-sm |
8 |
a(href=`/books/show/${book.id}`) #{book.name} |
9 |
p= book.description |
You simply loop through the books returned and output the name and description of each book using an unordered list. The name of the book points to the individual page of the book.
Add New Book Routes and View
The next router you set up will handle the addition of new books. Two routers will be used here: one will simply render the page, and another will handle the submission of the form.
This is how the routers look.
1 |
router.get("/add", (req, res, next) => { |
2 |
const genres = Genre.find({}) |
3 |
.exec() |
4 |
.then((genres) => { |
5 |
res.render("addBooks", { genres }); |
6 |
})
|
7 |
.catch((err) => { |
8 |
throw err; |
9 |
});
|
10 |
});
|
11 |
|
12 |
router.post("/add", (req, res, next) => { |
13 |
req.checkBody("name", "Name is required").notEmpty(); |
14 |
req.checkBody("description", "Description is required").notEmpty(); |
15 |
req.checkBody("genre", "Genre is required").notEmpty; |
16 |
|
17 |
const errors = req.validationErrors(); |
18 |
|
19 |
if (errors) { |
20 |
console.log(errors); |
21 |
res.render("addBooks", { book, errors }); |
22 |
}
|
23 |
|
24 |
const book = new Book(req.body) |
25 |
.save() |
26 |
.then((data) => { |
27 |
res.redirect("/books"); |
28 |
})
|
29 |
.catch((errors) => { |
30 |
console.log("oops..."); |
31 |
});
|
32 |
});
|
In the first router, you are displaying the /addBooks page. This router is called when a request is made to the /add path. Since books added are supposed to have genres, you want to display the genres that have been saved to the database.
1 |
const genres = Genre.find({}).exec() |
2 |
.then((genres) => { |
The code above finds all the genres in your database and returns them in the variable genres. With this, you will be able to loop through the genres and display them as checkboxes.
The second router handles the submission of the form. First, you check the body of the request to ensure that some fields are not empty. This is where the express-validator
middleware you set in app.js comes in handy. If there are errors, the page is rendered again. If there are none, the new Book instance is saved and the user is redirected to the /books page.
Let's go ahead and create the views for this.
Create a new view file called addBooks.pug. Note that the name of the view matches the first parameter given to res.render
. This is because you are rendering a template. During redirection, you simply pass the path you want to redirect to, as you did with res.redirect('/books')
.
Having established that, here is what the views should look like in views/addGenre.pug.
1 |
extends layout |
2 |
|
3 |
block content |
4 |
.row |
5 |
.col-md-12 |
6 |
h1 Add Book |
7 |
form(method="POST", action="/genres/add") |
8 |
.form-group |
9 |
label.col-lg-2.control.label Name |
10 |
.col-lg-10 |
11 |
input.form-control(type="text", name='name') |
12 |
.form-group |
13 |
.col-lg-10.col-lg-offset-2 |
14 |
input.button.btn.btn-primary(type='submit', value='Submit') |
15 |
|
16 |
if errors |
17 |
ul |
18 |
for error in errors |
19 |
li!= error.msg |
The important thing to note here is the form action and method. When the submit button is clicked, you are making a POST request to /books/add. One other thing—once again you loop through the collection of genres returned and display each of them.
Book Show Route and View
Let us drop in the route to handle the requests made to each books page in routes.books.js. While you are there, it is important to export your module too.
1 |
router.get('/show/:id', (req, res, next) => { |
2 |
const book = Book.findById({ _id: req.params.id }) |
3 |
.populate({ |
4 |
path: 'genre', |
5 |
model: 'Genre', |
6 |
populate: { |
7 |
path: 'genre', |
8 |
model: 'Book' |
9 |
}
|
10 |
})
|
11 |
.exec() |
12 |
.then((book) => { |
13 |
res.render('book', { book }) |
14 |
})
|
15 |
.catch((err) => { |
16 |
throw err |
17 |
})
|
18 |
})
|
19 |
|
20 |
export default router |
No magic is happening here.
First, requests made to this router must have an id: the id of the book. This id is obtained from the params of the request using req.params.id
. This is used to identify the specific book that should be obtained from the database, as the ids are unique. When the book is found, the genre value of the book is populated with all the genres that have been saved to this book instance. If all goes well, the book view is rendered, else an error is thrown.
Let's create the view for a book. Here is how it should look.
1 |
block content |
2 |
.well.well-lg |
3 |
h1 #[strong Name:] #{book.name} |
4 |
ul |
5 |
li #[strong Description:] #{book.description} |
6 |
li #[strong Author]: #{book.author} |
7 |
li #[strong Genre:] |
8 |
each genre in book.genre |
9 |
#{genre.name} |
10 |
|, |
You can start up your node server by running:
1 |
DEBUG=tutsplus-library:* npm start |
Conclusion
Now you know how to build a standard web application in Node.js, not just a simple to-do app. You were able to handle form submission, reference two models, and set up some middleware. The full example code is here.
You can go further by extending the application—try adding the ability to delete a book. First add a button to the show page, and then go to the routes files and add a router for this. Note that this is going to be a POST request.
You can also think of more features to add to the application. I hope you enjoyed it.
This post has been updated with contributions from Jacob Jackson. Jacob is a web developer, technical writer, freelancer, and open-source contributor.