How to Build and Structure a Node.js MVC Application

Share this article

Inside the monitor a puppet manipulates on-screen windows and popups

In a non-trivial application, the architecture is as important as the quality of the code itself. We can have well-written pieces of code, but if we don’t have good organization, we’ll have a hard time as the complexity increases. There’s no need to wait until the project is half-way done to start thinking about the architecture; the best time is before starting, using our goals as beacons for our choices.

Node.js doesn’t have a de facto framework with strong opinions on architecture and code organization in the same way that Ruby has the Rails framework, for example. As such, it can be difficult to get started with building full web applications with Node.

In this tutorial, we’re going to build the basic functionality of a note-taking app using the MVC architecture. To accomplish this, we’re going to employ the Hapi.js framework for Node.js and SQLite as a database, using Sequelize.js, plus other small utilities, to speed up our development. We’re going to build the views using Pug, the templating language.

Key Takeaways

  • Utilize the MVC (Model-View-Controller) architecture to organize a Node.js application effectively, enhancing manageability and scalability.
  • Start by setting up a Node.js environment and creating a package.json file using npm to manage dependencies.
  • Employ the Hapi.js framework and SQLite database with Sequelize.js for building the server and managing data, respectively.
  • Define routes in Node.js to outline the application’s functionalities, ensuring each route is associated with the correct HTTP method and handler.
  • Build models using Sequelize.js to interact with the database, ensuring data integrity and providing a layer of abstraction.
  • Implement controllers to handle business logic, bridging models and views by fetching data and deciding the output format.
  • Create views using the Pug templating engine to render user interfaces, making HTTP responses more readable and structured.
  • Serve static files using the Inert plugin with Hapi.js to manage resources like CSS and JavaScript files efficiently.

What is MVC?

Model-View-Controller (or MVC) is probably one of the most popular architectures for applications. As with a lot of other cool things in computer history, the MVC model was conceived at PARC for the Smalltalk language as a solution to the problem of organizing applications with graphical user interfaces. It was created for desktop applications, but since then, the idea has been adapted to other mediums including the Web.

We can describe the MVC architecture in simple terms:

  • Model: the part of our application that will deal with the database or any data-related functionality.
  • View: everything the user will see — basically, the pages that we’re going to send to the client.
  • Controller: the logic of our site, and the glue between models and views. Here we call our models to get the data, then we put that data on our views to be sent to the users.

Our application will allow us to create, view, edit and delete plain-text notes. It won’t have other functionality, but because we’ll have a solid architecture already defined we won’t have a lot of trouble adding things later.

This tutorial assumes you have a recent version of Node installed on your machine. If this isn’t the case, please consult our tutorial on getting up and running with Node.

You can check out the final application in the accompanying GitHub repository, so you get a general overview of the application structure.

Laying out the Foundation

The first step when building any Node.js application is to create a package.json file, which is going to contain all of our dependencies and scripts. Instead of creating this file manually, npm can do the job for us using the init command:

mkdir notes-board
cd notes-board
npm init -y

After the process is complete, we’ll have a package.json file ready to use.

Note: if you’re not familiar with these commands, checkout our Beginner’s Guide to npm.

We’re going to proceed to install Hapi.js — the framework of choice for this tutorial. It provides a good balance between simplicity, stability and features that will work well for our use case (although there are other options that would also work just fine).

npm install @hapi/hapi@18.4.0

This command will download Hapi.js and add it to our package.json file as a dependency.

Note: We’ve specified v18.4.0 of Hapi.js, as it’s compatible with Node versions 8, 10, and 12. If you’re using Node 12, you can opt to install the latest version (Hapi v19.1.0).

Now we can create our entry file — the web server that will start everything. Go ahead and create a server.js file in your application directory and add the following code to it:

"use strict";

const Hapi = require("@hapi/hapi");
const Settings = require("./settings");

const init = async () => {
  const server = new Hapi.Server({ port: Settings.port });

  server.route({
    method: "GET",
    path: "/",
    handler: (request, h) => {
      return "Hello, world!";
    }
  });

  await server.start();
  console.log(`Server running at: ${server.info.uri}`);
};

process.on("unhandledRejection", err => {
  console.log(err);
  process.exit(1);
});

init();

This is going to be the foundation of our application.

First, we indicate that we’re going to use strict mode, which is a common practice when using the Hapi.js framework.

Next, we include our dependencies and instantiate a new server object where we set the connection port to 3000 (the port can be any number above 1023 and below 65535).

Our first route for our server will work as a test to see if everything is working, so a “Hello, world!” message is enough for us. In each route, we have to define the HTTP method and path (URL) that it will respond to, and a handler, which is a function that will process the HTTP request. The handler function can take two arguments: request and h. The first one contains information about the HTTP call, and the second will provide us with methods to handle our response to that call.

Finally, we start our server with the server.start() method.

Storing Our Settings

It’s good practice to store our configuration variables in a dedicated file. This file exports a JSON object containing our data, where each key is assigned from an environment variable — but without forgetting a fallback value.

In this file, we can also have different settings depending on our environment (such as development or production). For example, we can have an in-memory instance of SQLite for development purposes, but a real SQLite database file on production.

Selecting the settings depending on the current environment is quite simple. Since we also have an env variable in our file which will contain either development or production, we can do something like the following to get the database settings:

const dbSettings = Settings[Settings.env].db;

So dbSettings will contain the setting of an in-memory database when the env variable is development, or will contain the path of a database file when the env variable is production.

Also, we can add support for a .env file, where we can store our environment variables locally for development purposes. This is accomplished using a package like dotenv for Node.js, which will read a .env file from the root of our project and automatically add the found values to the environment.

Note: if you decide to also use a .env file, make sure you install the package with npm install dotenv and add it to .gitignore so you don’t publish any sensitive information.

Our settings.js file will look like this:

// This will load our .env file and add the values to process.env,
// IMPORTANT: Omit this line if you don't want to use this functionality
require("dotenv").config({ silent: true });

module.exports = {
  port: process.env.PORT || 3000,
  env: process.env.NODE_ENV || "development",

  // Environment-dependent settings
  development: {
    db: {
      dialect: "sqlite",
      storage: ":memory:"
    }
  },
  production: {
    db: {
      dialect: "sqlite",
      storage: "db/database.sqlite"
    }
  }
};

Now we can start our application by executing the following command and navigating to http://localhost:3000 in our web browser:

node server.js

Note: this project was tested on Node v12.15.0. If you get any errors, ensure you have an updated installation.

Defining the Routes

The definition of routes gives us an overview of the functionality supported by our application. To create our additional routes, we just have to replicate the structure of the route that we already have in our server.js file, changing the content of each one.

Let’s start by creating a new directory called lib in our project. Here we’re going to include all the JS components.

Inside lib, let’s create a routes.js file and add the following content:

"use strict";
const Path = require("path");

module.exports = [
  // we’re going to define our routes here
];

In this file, we’ll export an array of objects that contain each route of our application. To define the first route, add the following object to the array:

{
  method: "GET",
  path: "/",
  handler: (request, h) => {
    return "All the notes will appear here";
  },
  config: {
    description: "Gets all the notes available"
  }
},

Our first route is for the home page (/), and since it will only return information, we assign it a GET method. For now, it will only give us the message “All the notes will appear here”, which we’re going to change later for a controller function. The description field in the config section is only for documentation purposes.

Then, we create the four routes for our notes under the /note/ path. Since we’re building a CRUD application, we’ll need one route for each action with the corresponding HTTP methods.

Add the following definitions next to the previous route:

{
  method: "POST",
  path: "/note",
  handler: (request, h) => {
    return "New note";
  },
  config: {
    description: "Adds a new note"
  }
},
{
  method: "GET",
  path: "/note/{slug}",
  handler: (request, h) => {
    return "This is a note";
  },
  config: {
    description: "Gets the content of a note"
  }
},
{
  method: "PUT",
  path: "/note/{slug}",
  handler: (request, h) => {
    return "Edit a note";
  },
  config: {
    description: "Updates the selected note"
  }
},
{
  method: "GET",
  path: "/note/{slug}/delete",
  handler: (request, h) => {
    return "This note no longer exists";
  },
  config: {
    description: "Deletes the selected note"
  }
}

We’ve done the same as in the previous route definition, but this time we’ve changed the method to match the action we want to execute.

The only exception is the delete route. In this case, we’re going to define it with the GET method rather than DELETE and add an extra /delete in the path. This way, we can call the delete action just by visiting the corresponding URL.

Note: if you plan to implement a strict REST interface, then you would have to use the DELETE method and remove the /delete part of the path.

We can name parameters in the path by surrounding the word in curly braces. Since we’re going to identify notes by a slug, we add {slug} to each path, with the exception of the POST route; we don’t need it there because we’re not going to interact with a specific note, but to create one.

You can read more about Hapi.js routes on the official documentation.

Now, we have to add our new routes to the server.js file. Let’s import the routes file at the top of the file:

const Routes = require("./lib/routes");

Then let’s replace our current test route with the following:

server.route(Routes);

Building the Models

Models allow us to define the structure of the data and all the functions to work with it.

In this example, we’re going to use the SQLite database with Sequelize.js, which is going to provide us with a better interface using the ORM (Object-Relational Mapping) technique. It will also provide us a database-independent interface.

Setting up the Database

You can install SQLite and Sequelize by executing the following command:

npm install sequelize sqlite3

Now create a models directory inside lib/ with a file called index.js, which is going to contain the database and Sequelize.js setup, and include the following content:

"use strict";

const Fs = require("fs");
const Path = require("path");
const Sequelize = require("sequelize");
const Settings = require("../../settings");
const dbSettings = Settings[Settings.env].db;

const sequelize = new Sequelize(
  dbSettings.database,
  dbSettings.user,
  dbSettings.password,
  dbSettings
);
const db = {};

Fs.readdirSync(__dirname)
  .filter(file => file.indexOf(".") !== 0 && file !== "index.js")
  .forEach(file => {
    const model = sequelize.import(Path.join(__dirname, file));
    db[model.name] = model;
  });

db.sequelize = sequelize;
db.Sequelize = Sequelize;

module.exports = db;

First, we include the modules that we’re going to use:

  • Fs, to read the files inside the models folder, which is going to contain all the models
  • Path, to join the path of each file in the current directory
  • Sequelize, which will allow us to create a new Sequelize instance
  • Settings, which contains the data of our settings.js file from the root of our project

Next, we create a new sequelize variable that will contain a Sequelize instance with our database settings for the current environment. We’re going to use sequelize to import all the models and make them available in our db object.

The db object is going to be exported and will contain our database methods for each model. It will be available in our application when we need to do something with our data.

To load all the models, instead of defining them manually, we look for all the files inside the models directory (with the exception of the index.js file) and load them using the import function. The returned object will provide us with the CRUD methods, which we then add to the db object.

At the end, we add sequelize and Sequelize as part of our db object. The first one is going to be used in our server.js file to connect to the database before starting the server, and the second one is included for convenience if you need it in other files too.

Creating Our Note Model

In this section, we’re going to use the Moment.js package to help with date formatting. You can install it and include it as a dependency with the following command:

npm install moment

We’re going to create a note.js file inside the models directory, which is going to be the only model in our application. It will provide us with all the functionality we need.

Add the following content to that file:

"use strict";

const Moment = require("moment");

module.exports = (sequelize, DataTypes) => {
  const Note = sequelize.define("Note", {
    date: {
      type: DataTypes.DATE,
      get: function() {
        return Moment(this.getDataValue("date")).format("MMMM Do, YYYY");
      }
    },
    title: DataTypes.STRING,
    slug: DataTypes.STRING,
    description: DataTypes.STRING,
    content: DataTypes.STRING
  });

  return Note;
};

We export a function that accepts a sequelize instance, to define the model, and a DataTypes object with all the types available in our database.

Next, we define the structure of our data using an object where each key corresponds to a database column and the value of the key defines the type of data we’re going to store. You can see the list of data types in the Sequelize.js documentation. The tables in the database are going to be created automatically based on this information.

In the case of the date column, we also define a how Sequelize should return the value using a getter function (get key). We indicate that before returning the information. It should be first passed through the Moment utility to be formatted in a more readable way (MMMM Do, YYYY).

Note: although we’re getting a simple and easy-to-read date string, it’s stored as a precise date string product of JavaScript’s Date object. So this is not a destructive operation.

Finally, we return our model.

Synchronizing the Database

We now have to synchronize our database before we’re able to use it in our application. In server.js, import the models at the top of the file:

// Import the index.js file inside the models directory
const Models = require("./lib/models/");

Next, remove the following code block:

await server.start();
console.log(`Server running at: ${server.info.uri}`);

Replace it with this one:

await Models.sequelize.sync();

await server.start();
console.log(`Server running at: ${server.info.uri}`);

This code is going to synchronize the models to our database. Once that’s done, the server will be started.

Building the Controllers

Controllers are functions that accept the request and response toolkit objects from Hapi.js. The request object contains information about the requested resource, and we use reply to return information to the client.

In our application, we’re going to return only a JSON object for now, but we’ll add the views once we build them.

We can think of controllers as functions that will join our models with our views; they will communicate with our models to get the data, and then return that data inside a view.

The Home Controller

The first controller that we’re going to build will handle the home page of our site. Create a home.js file inside a lib/controllers directory with the following content:

"use strict";

const Models = require("../models/");

module.exports = async (request, h) => {
  const result = await Models.Note.findAll({
    order: [["date", "DESC"]]
  });

  return {
    data: {
      notes: result
    },
    page: "Home — Notes Board",
    description: "Welcome to my Notes Board"
  };
};

First, we get all the notes in our database using the findAll method of our model. This function will return a Promise and, if it resolves, we’ll get an array containing all the notes in our database.

We can arrange the results in descending order, using the order parameter in the options object passed to the findAll method, so the last item will appear first. You can check all the available options in the Sequelize.js documentation.

Once we have the home controller, we can edit our routes.js file. First, we import the module at the top of the file, next to the Path module import:

const Home = require("./controllers/home");

Then we add the controller we just made to the array:

{
  method: "GET",
  path: "/",
  handler: Home,
  config: {
    description: "Gets all the notes available"
  }
},

You can check things are working at this point by restarting the server (node server.js) and visiting http://localhost:3000/. You should see the following response:

{
  "data": { "notes": [] },
  "page":"Home — Notes Board",
  "description":"Welcome to my Notes Board"
}

Boilerplate of the Note Controller

Since we’re going to identify our notes with a slug, we can generate one using the title of the note and the slug library, so let’s install it and include it as a dependency with the following command:

npm install slug

The last controller that we have to define in our application will allow us to create, read, update, and delete notes.

We can proceed to create a note.js file inside the lib/controllers directory and add the following content:

"use strict";

const { Note } = require("../models/");
const Slugify = require("slug");
const Path = require("path");

module.exports = {
  // Here we’re going to include our functions that will handle the remaining requests in the routes.js file.
};

The create Function

To add a note to our database, we’re going to write a create function that’s going to wrap the create method on our model using the data contained in the payload object.

Add the following inside the object that we’re exporting:

create: async (request, h) => {
  const result = await Note.create({
    date: new Date(),
    title: request.payload.noteTitle,
    slug: Slugify(request.payload.noteTitle, { lower: true }),
    description: request.payload.noteDescription,
    content: request.payload.noteContent
  });

  // Generate a new note with the 'result' data
  return result;
},

Once the note is created, we’ll get back the note data and send it to the client as JSON using the reply function.

For now, we just return the result, but once we build the views in the next section, we’ll be able to generate the HTML with the new note and add it dynamically on the client. Although this is not completely necessary and will depend on how you’re going to handle your front-end logic, we’re going to return an HTML block to simplify the logic on the client.

Also, note that the date is being generated on the fly when we execute the function, using new Date().

The read Function

To search just one element, we use the findOne method on our model. Since we identify notes by their slug, the where filter must contain the slug provided by the client in the URL (http://localhost:3000/note/:slug:):

read: async (request, h) => {
  const note = await Note.findOne({
    where: {
      slug: request.params.slug
    }
  });

  return note;
},

As in the previous function, we’ll just return the result, which is going to be an object containing the note information. The views are going to be used once we build them in the Building the Views section.

The update Function

To update a note, we use the update method on our model. It takes two objects — the new values that we’re going to replace, and the options containing a where filter with the note slug, which is the note that we’re going to update:

update: async (request, h) => {
  const values = {
    title: request.payload.noteTitle,
    description: request.payload.noteDescription,
    content: request.payload.noteContent
  };

  const options = {
    where: {
      slug: request.params.slug
    }
  };

  await Note.update(values, options);
  const result = await Note.findOne(options);

  return result;
},

After updating our data, since our database won’t return the updated note, we can find the modified note again to return it to the client, so we can show the updated version as soon as the changes are made.

The delete Function

The delete controller will remove the note by providing the slug to the destroy function of our model. Then, once the note is deleted, we redirect to the home page. To accomplish this, we use the redirect function of Hapi’s response toolkit:

delete: async (request, h) => {
  await Note.destroy({
    where: {
      slug: request.params.slug
    }
  });

  return h.redirect("/");
}

Using the Note Controller in Our Routes

At this point, we should have our note controller file ready with all the CRUD actions. But to use them, we have to include it in our routes file.

First, let’s import our controller at the top of the routes.js file:

const Note = require("./controllers/note");

We have to replace each handler with our new functions, so we should have our routes file as follows:

{
  method: "POST",
  path: "/note",
  handler: Note.create,
  config: {
    description: "Adds a new note",
    payload: {
      multipart: true,
    }
  }
},
{
  method: "GET",
  path: "/note/{slug}",
  handler: Note.read,
  config: {
    description: "Gets the content of a note"
  }
},
{
  method: "PUT",
  path: "/note/{slug}",
  handler: Note.update,
  config: {
    description: "Updates the selected note",
    payload: {
      multipart: true,
    }
  }
},
{
  method: "GET",
  path: "/note/{slug}/delete",
  handler: Note.delete,
  config: {
    description: "Deletes the selected note"
  }
}

Note: we’re including our functions without () at the end, because we’re referencing our functions without calling them.

In Hapi v19, request.payload.multipart was changed to false by default. We need to set it back to true for the POST and PUT routes, as we’ll be using a FormData object to transmit data to the server, and the transmitted data will be in the multipart/form-data format.

Building the Views

At this point, our site is receiving HTTP calls and responding with JSON objects. To make it useful to everybody, we have to create the pages that render our information in a nice way.

In this example, we’re going to use the Pug (formerly Jade) templating language, although this isn’t mandatory, and we can use other languages with Hapi.js. We’re going to use the Vision plugin to enable the view functionality in our server.

Note: if you’re not familiar with Jade/Pug, see our Beginner’s Guide to Pug.

You can install the packages with the following command:

npm install @hapi/vision@5.5.4 pug

Here we’re installing v5.5.4 of the vision plugin, which is compatible with Hapi v18. If you’ve opted to install Hapi v19, you can simply type npm i @hapi/vision to pull in the latest version.

The Note Component

First, we’re going to build the note component that’s going to be reused across our views. Also, we’re going to use this component in some of our controller functions to build a note on the fly in the back end to simplify the logic on the client.

Create a file in lib/views/components called note.pug with the following content:

article.content
  h2.title: a(href=`/note/${note.slug}`)= note.title
  p.subtitle.is-6 Published on #{note.date}
  p=note.content

It’s composed of the title of the note, the publication date and the content of the note.

The Base Layout

The base layout contains the common elements of our pages — or in other words, for our example, everything that’s not content. Create a file in lib/views/ called layout.pug with the following content:

doctype html
head
  meta(charset='utf-8')
  meta(name='viewport' content='width=device-width, initial-scale=1')
  title=page
  meta(name='description' content=description)
  link(rel='stylesheet' href='https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css')
  script(defer='' src='https://use.fontawesome.com/releases/v5.3.1/js/all.js')
body
  block content
  script(src='/scripts/main.js')

The content of the other pages will be loaded in place of block content. Also, note that we’ll display a page variable in the title element, and a description variable in the meta(name='description') element. We’ll create those variables in our routes later.

For styling purposes, we’re including the Bulma CSS framework and Font Awesome from a CDN. We’re also including a main.js file at the bottom of the page, which will contain all of our custom JavaScript code for the front end. Please create that file now in a static/public/scripts/ directory.

The Home View

On our home page, we’ll show a list of all the notes in our database and a button that will show a modal window with a form that allows us to create a new note via Ajax.

Create a file in lib/views called home.pug with the following content:

extends layout

block content
  section.section
    .container

      h1.title.has-text-centered
        | Notes Board

      .tabs.is-centered
        ul
          li
            a.show-modal(href='#') Publish

      main(container).notes-list
        each note in data.notes
          include components/note
          hr

      .modal
        .modal-background
        .modal-card
          header.modal-card-head
            p.modal-card-title Add note
            button.delete(aria-label='close')
          section.modal-card-body
            form(action='/note' method='POST').note-form#note-form
              .field
                .control
                  input.input(name='noteTitle' type='text' placeholder='Title')
              .field
                .control
                  input.input(name='noteDescription' type='text' placeholder='Short description')
              .field
                .control
                  textarea.textarea(name='noteContent' placeholder='Contents')
              .field
                .control
                  button.button.is-link Save

The Note View

The note page is pretty similar to the home page, but in this case, we show a menu with options specific to the current note, the content of the note and the same form as in the home page, but with the current note information already filled, so it’s there when we update it.

Create a file in lib/views called note.pug with the following content:

extends layout

block content
  section.section
    .container
      h1.title.has-text-centered
          | Notes Board

      .tabs.is-centered
        ul
          li: a(href='/') Home
          li: a.show-modal(href='#') Update
          li: a(href=`/note/${note.slug}/delete`) Delete

      include components/note

      .modal
        .modal-background
        .modal-card
          header.modal-card-head
            p.modal-card-title Edit note
            button.delete(aria-label='close')
          section.modal-card-body
            form(action=`/note/${note.slug}` method='PUT').note-form#note-form
              .field
                .control
                  input.input(name='noteTitle' type='text' placeholder='Title' value=note.title)
              .field
                .control
                  input.input(name='noteDescription' type='text' placeholder='Short description' value=note.description)
              .field
                .control
                  textarea.textarea(name='noteContent' placeholder='Contents') #{note.content}
              .field
                .control
                  button.button.is-link Save

The JavaScript on the Client

To create and update notes, we’ll use some JavaScript, both to show/hide a modal with a form, and to submit the requests via Ajax. Although this is not strictly necessary, we feel it provides a better experience for the user.

This is the content of our main.js file in the static/public/scripts/ directory:

// Modal

const modal = document.querySelector(".modal");
const html = document.querySelector("html");

const showModal = () => {
  modal.classList.add("is-active");
  html.classList.add("is-clipped");
};

const hideModal = () => {
  modal.classList.remove("is-active");
  html.classList.remove("is-clipped");
};

document.querySelector("a.show-modal").addEventListener("click", function(e) {
  e.preventDefault();
  showModal();
});

modal.querySelector(".modal .delete").addEventListener("click", function(e) {
  e.preventDefault();
  hideModal();
});

// Form submition

const form = document.querySelector("#note-form");
const url = form.getAttribute("action");
const method = form.getAttribute("method");

const prependNote = html => {
  const notesList = document.querySelector(".notes-list");
  const div = document.createElement("div");
  div.innerHTML = html;
  notesList.insertBefore(div.firstChild, notesList.firstChild);
};

const updateNote = html => {
  const article = document.querySelector("article");
  const div = document.createElement("div");
  div.innerHTML = html;
  article.parentNode.replaceChild(div.firstChild, article);
};

const onSuccess = html => {
  hideModal();
  form.reset();

  if (method === "POST") {
    prependNote(html);
  } else if (method === "PUT") {
    updateNote(html);
  }
};

form.addEventListener("submit", e => {
  e.preventDefault();

  fetch(url, {
    method,
    body: new FormData(form)
  })
    .then(response => response.text())
    .then(text => onSuccess(text))
    .catch(error => console.error(error));
});

Every time the user submits the form in the modal window, we get the information from the form elements and send it to our back end, depending on the action URL and the method (POST or PUT). Then, we’ll get the result as a block of HTML containing our new note data. When we add a note, we’ll just add it on top of the list on the home page, and when we update a note we replace the content for the new one in the note view.

Adding Support for Views on the Server

To make use of our views, we have to include them in our controllers and add the required settings.

In our server.js file, let’s import the Node Path utility at the top of the file, since we’re using it in our code to indicate the path of our views:

const Path = require("path");

Now, replace the server.route(Routes); line with the following code block:

await server.register([require("@hapi/vision")]);

server.views({
  engines: { pug: require("pug") },
  path: Path.join(__dirname, "lib/views"),
  compileOptions: {
    pretty: false
  },
  isCached: Settings.env === "production"
});

// Add routes
server.route(Routes);

In the code we’ve added, we first register the Vision plugin with our Hapi.js server, which is going to provide the view functionality. Then we add the settings for our views — like the engine we’re going to use and the path where the views are located. At the end of the code block, we add our routes back in.

This will make our views work on the server, but we still have to declare the view that we’re going to use for each route.

Setting the Home View

Open the lib/controllers/home.js file and replace the return statement with the following:

return h.view('home', {
  data: {
    notes: result
  },
  page: 'Home — Notes Board',
  description: 'Welcome to my Notes Board'
});

After registering the Vision plugin, we now have a view method available on the reply object. We’re going to use it to select the home view in our views directory and to send the data that’s going to be used when rendering the views.

In the data that we provide to the view, we also include the page title and a meta description for search engines.

If you’d like to try things out at this point, head to http://localhost:3000/. You should see a nicely styled notes board, with a Publish button that doesn’t do anything.

Setting the Note View: create Function

Right now, every time we create a note we send a JSON object from the server to the client. But since we’re doing this process with Ajax, we can send the new note as HTML ready to be added to the page. To do this, we render the note component with the data we have.

Start off by requiring Pug at the top of the controllers/note.js file:

const Pug = require("pug");

Then, in the create method, replace the line return result; with the following code block:

// Generate a new note with the 'result' data
return Pug.renderFile(
  Path.join(__dirname, "../views/components/note.pug"),
  {
    note: result
  }
);

We use the renderFile method from Pug to render the note template with the data we just received from our model.

Setting the Note View: read Function

When we enter a note page, we should get the note template with the content of our note. To do this, we have to replace the read function’s return note; line with this:

return h.view("note", {
  note,
  page: `${note.title} — Notes Board`,
  description: note.description
});

As with the home page, we select a view as the first parameter and the data that we’re going to use as the second one.

Setting the Note View: update Function

Every time we update a note, we’ll reply similarly to when we create new notes. Replace the return result; line in the update function with the following code:

// Generate a new note with the updated data
return Pug.renderFile(
  Path.join(__dirname, "../views/components/note.pug"),
  {
    note: result
  }
);

Note: the delete function doesn’t need a view, since it will just redirect to the home page once the note is deleted.

Serving Static Files

The JavaScript and CSS files that we’re using on the client side are provided by Hapi.js from the static/public/ directory. But it won’t happen automatically; we have to indicate to the server that we want to define this folder as public. This is done using the Inert package, which you can install with the following command:

npm install @hapi/inert

In the server.register function inside the server.js file, import the Inert plugin and register it with Hapi like this:

await server.register([require("@hapi/vision"), require("@hapi/inert")]);

Now we have to define the route where we’re going to provide the static files, and their location on our server’s filesystem. Add the following entry at the end of the exported object in routes.js:

{
  // Static files
  method: "GET",
  path: "/{param*}",
  handler: {
    directory: {
      path: Path.join(__dirname, "../static/public")
    }
  },
  config: {
    description: "Provides static resources"
  }
}

This route will use the GET method, and we’ve replaced the handler function with an object containing the directory that we want to make public.

You can find more information about serving static content in the Hapi.js documentation.

Conclusion

At this point, we have a very basic Hapi.js application using the MVC architecture. Although there are still things we should take care of before putting our application in production (such as input validation, error handling, error pages, and so on) this should work as a foundation to learn and build your own applications.

If you’d like to take this example a bit further, after finishing all the small details (not related the architecture) to make this a robust application, you could implement an authentication system so only registered users are able to publish and edit notes. But your imagination is the limit, so feel free to fork the application repository and go to town!

Dive deeper into Node.js with further reading:

Frequently Asked Questions (FAQs) about Node.js MVC Application

What are the key components of a Node.js MVC application?

A Node.js MVC (Model-View-Controller) application is composed of three main components. The Model is responsible for managing the data of the application. It receives user input from the Controller. The View is the part of the application that represents the presentation of data. The Controller acts as an interface between Model and View, receiving user input and deciding what to do with it. These components work together to provide a robust and scalable structure for web applications.

How does the MVC architecture benefit Node.js applications?

MVC architecture provides a clear and organized structure for Node.js applications. It separates concerns, making the code easier to manage and maintain. It also promotes modular development, where different parts of the application can be developed simultaneously with minimal dependencies. This architecture also makes it easier to scale the application and to reuse code, which can significantly speed up the development process.

How can I set up a Node.js MVC application?

Setting up a Node.js MVC application involves several steps. First, you need to install Node.js and npm (Node Package Manager) on your system. Then, you can use Express, a popular Node.js framework, to set up the server. You can then define your Model, View, and Controller. The Model can be set up using a database like MongoDB or MySQL. The View can be set up using a template engine like EJS or Pug. The Controller can be set up as a series of middleware functions that handle HTTP requests and responses.

What are some best practices for structuring a Node.js MVC application?

Some best practices for structuring a Node.js MVC application include keeping the code DRY (Don’t Repeat Yourself), organizing code into modules, using middleware for common tasks, and keeping business logic in the Model. It’s also important to handle errors properly and to write tests for your code to ensure it works as expected.

How can I handle user input in a Node.js MVC application?

In a Node.js MVC application, user input is typically handled in the Controller. The Controller receives the input, validates it, and then passes it to the Model. The Model then processes the data and returns a response, which the Controller sends back to the user.

How can I render views in a Node.js MVC application?

In a Node.js MVC application, views are typically rendered using a template engine. The template engine takes data from the Model and inserts it into a template to create HTML. This HTML is then sent to the client by the Controller.

How can I connect a Node.js MVC application to a database?

Connecting a Node.js MVC application to a database involves setting up a Model that interacts with the database. You can use an ORM (Object-Relational Mapping) library like Sequelize or Mongoose to simplify this process. These libraries provide methods for creating, reading, updating, and deleting data in the database.

How can I handle errors in a Node.js MVC application?

Error handling in a Node.js MVC application typically involves using middleware functions. These functions can catch errors that occur during the execution of the application and send appropriate responses to the client.

How can I test a Node.js MVC application?

Testing a Node.js MVC application can be done using testing frameworks like Mocha or Jest. These frameworks provide functions for defining and running tests. You can write tests for your Model, View, and Controller to ensure they work as expected.

How can I deploy a Node.js MVC application?

Deploying a Node.js MVC application involves several steps. First, you need to prepare your application for production by minifying and bundling your code. Then, you can use a service like Heroku or AWS to host your application. You also need to set up a production database and configure your application to use it. Finally, you can use a continuous integration/continuous deployment (CI/CD) tool to automate the deployment process.

James KolceJames Kolce
View Author

Independent Web Developer, Designer and Technical Writer.

Nilson JacquesNilson Jacques
View Author

Nilson is a full-stack web developer who has been working with computers and the web for over a decade. A former hardware technician, and network administrator. Nilson is now currently co-founder and developer of a company developing web applications for the construction industry. You can also find Nilson on the SitePoint Forums as a mentor.

architecturehapijademvcnilsonjnodenode-hubNode-JS-Tutorialsnode.jsPugSequelize
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week