DEV Community

Cover image for Build an isomorphic application with Nuxt.js and Node
Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Build an isomorphic application with Nuxt.js and Node

Written by Jordan Irabor✏️

Introduction

Single-page applications (SPAs) transformed the way internet users interact with web applications. A SPA is an application that improves user experience by dynamically updating the content of a single page, rather than fetching every new page from a server. These kind of web applications offer the following benefits:

Pleasant routing

There is no page reload as users move from one page to another and this can give the feel of a native application rather than a web application. Some developers add transition effects on each navigation to give an even smoother experience.

Consumes less bandwidth

SPAs do not have to fetch entire page documents from a server after the main JavaScript bundle has loaded. This reduces the bandwidth used in data exchange and makes the web applications easy to use with slow internet connections.

Fast load time

In traditional web applications, the browser sends a request to the server for an HTML file on each page navigation. SPAs only send this request once, on the first load. Any other data needed will be dynamically retrieved and injected. This makes SPAs faster than regular websites as they do not have to load new pages when users navigate the application.

While the concept of a SPA is shiny and packed with a lot of advantages, it also introduces a few disadvantages because of its design. Some of these disadvantages are:

  • The initial page load time is usually slow because the JavaScript bundle needed to run the application dynamically is heavy
  • Because the web application is client-side rendered, some search engine web crawlers and social network robots do not see the content for the application when they crawl the pages

LogRocket Free Trial Banner

What are isomorphic applications?

Isomorphic applications, as described here, were designed to solve the problems discussed above:

Isomorphic applications, also known as Universal JavaScript applications are JavaScript applications that preload data using server-side rendering before running the application on the client-side. This ensures that all content is available for crawlers and other bots to index.

Setting up a server-side rendered JavaScript application from scratch can be a hassle as a lot of configuration is required. This is the problem Nuxt aims to solve for Vue developers, the official Nuxt website describes it as:

Nuxt is a progressive framework based on Vue, it is used to create modern server-side rendered web applications. You can use Nuxt as a framework to handle all the UI rendering of your application while preloading its data on the server-side.

This schema shows what happens under the hood, in a Nuxt application, when the server is called or when the user navigates through a Nuxt application:

nuxt-schema
nuxt-schema

In this article, we will build an isomorphic pet adoption website using Nuxt and Node. Here’s a demo of how the final application will work:

node and nuxt app

Let’s get started.

Prerequisites

You’ll need the following for this tutorial:

For reference, the source code for this tutorial is available on GitHub.

Building the backend

We will separate the backend code from the frontend code by putting them in two different folders, but first, let’s create a parent directory to house the entire project:

$ mkdir isomorphic-application
$ cd isomorphic-application
Enter fullscreen mode Exit fullscreen mode

Let’s create the backend folder within the project directory:

$ mkdir backend
$ cd backend
Enter fullscreen mode Exit fullscreen mode

The first thing we want to do is to initialize a new npm project:

$ npm init -y
Enter fullscreen mode Exit fullscreen mode

Let’s install Nodemon to help us automatically refresh our server when we make code changes:

$ npm install nodemon -g
Enter fullscreen mode Exit fullscreen mode

We need these other dependencies to help us build the server, parse data, handle images, and log incoming requests:

$ npm install express cors request body-parser multer morgan mongoose crypto --save
Enter fullscreen mode Exit fullscreen mode

Let’s create the following folder structure in the backend directory:

backend
└── /models
    └── pet.js
└── /routes
    └── api.js
└── index.js
└── mock.js
Enter fullscreen mode Exit fullscreen mode

Note that there will be other files and folders (like node_modules) automatically generated in the backend directory.

Let’s start updating these files one by one to gradually become our backend server to handle and process requests. Paste in the following code in the models/pet.js file:

// models/pet.js

const mongoose = require('mongoose');

const Schema = mongoose.Schema;

const petSchema = new Schema({
    name: { type: String },
    type: { type: String },
    imageUrl: { type: String },
    description: { type: String }
})

module.exports = mongoose.model('Pet', petSchema);
Enter fullscreen mode Exit fullscreen mode

In the snippet above, we defined the schema for the pets we wanted to create and exported it as a Mongoose model. We want each pet to have the following fields:

  1. name
  2. type (maybe a cat or a dog)
  3. imageUrl (the address of its image)
  4. description

Now paste in the following code in the routes/api.js file:

// routes/api.js

const Pet = require('../models/pet');
const express = require('express');
const path = require('path')
const multer = require('multer')
const crypto = require('crypto')
const router = express.Router();

const storage = multer.diskStorage({
    destination: 'public',
    filename: (req, file, callback) => {
        crypto.pseudoRandomBytes(16, function (err, raw) {
            if (err) return callback(err);
            callback(null, raw.toString('hex') + path.extname(file.originalname));
        });
    }
});

let upload = multer({ storage: storage })

router.post('/pet/new', upload.single('image'), (req, res) => {
    if (!req.file) {
        console.log("Please include a pet image");
        return res.send({
            success: false
        });
    } else {
        const host = req.get('host')
        const imageUrl = req.protocol + "://" + host + '/' + req.file.path;
        Pet.create({
            name: req.body.name,
            type: req.body.type,
            description: req.body.description,
            imageUrl
        }, (err, pet) => {
            if (err) {
                console.log('CREATE error: ' + err);
                res.status(500).send('Error')
            } else {
                res.status(200).json(pet)
            }
        })
    }
})

router.get('/pet/:_id', (req, res) => {
    Pet.findById(req.params._id, (err, pet) => {
        if (err) {
            console.log('RETRIEVE error: ' + err);
            res.status(500).send('Error');
        } else if (pet) {
            res.status(200).json(pet)
        } else {
            res.status(404).send('Item not found')
        }
    })
})

router.get('/pets', (req, res) => {
    const pets = Pet.find({}, (err, pets) => {
        if (err) {
            console.log('RETRIEVE error: ' + err);
            res.status(500).send('Error');
        } else if (pets) {
            res.status(200).json(pets);
        }
    })
})

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

In the snippet above, we imported the Multer package and used it to define the destination for images on our local machine. We also used the Crypto package to generate a new random name for the images of pets that will be uploaded.

We used the Express router framework to create three routes:

  1. /pet/new handles the upload of new pet objects
  2. /pet/:_id finds and returns an existing pet to be rendered on the client-side
  3. /pets returns all pets

Finally, at the bottom of the snippet, we exported the router.

Open the backend/index.js file and paste in the following snippet:

// backend/index.js

const express = require('express');
const bodyParser = require('body-parser');
const mongoose = require('mongoose')
const morgan = require('morgan');
const api = require('./routes/api')
const pets = require('./mock')
const path = require('path');
const app = express()

app.use((req, res, next) => {
    res.header("Access-Control-Allow-Origin", "*");
    res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
    res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
    next();
})

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use('/api', api);
app.use(morgan('dev'));
app.use('/public', express.static(path.join(__dirname, 'public')));

mongoose.connect('mongodb://localhost:27017/pets', { useNewUrlParser: true });

const db = mongoose.connection;
db.on('error', console.error.bind(console, 'Connection Error'))
db.once('open', () => {
    app.listen(9000, () => {
        console.log('Running on port 9000')
    })
    const petCollection = db.collection('pets')
    petCollection.estimatedDocumentCount((err, count) => {
        if (count) return
        petCollection.insertMany(pets)
    })
})
Enter fullscreen mode Exit fullscreen mode

In the code above, we imported the dependencies we need (including a mock file that we’ve yet to create) and set headers to prevent CORS issues since the client-side application will run on a different port.

We registered the /public (our destination for images created by Multer) as a static URL and connected to MongoDB using the mongoose client. With this block of code below, we start the server on port 9000 and seed the database using the mock data if it is empty:

db.once('open', () => {
    app.listen(9000, () => {
        console.log('Running on port 9000')
    })
    const petCollection = db.collection('pets')
    petCollection.estimatedDocumentCount((err, count) => {
        if (count) return
        petCollection.insertMany(pets)
    })
})
Enter fullscreen mode Exit fullscreen mode

Let’s create the mock data now, paste the following code in the backend/mock.js file:

// backend/mock.js

const pets = [{
    'name': 'Calvin',
    'type': 'Dog',
    'imageUrl': 'https://placedog.net/636/660',
    'description': 'Great at giving warm hugs.'
},
{
    'name': 'Carly',
    'type': 'Dog',
    'imageUrl': 'https://placedog.net/660/636',
    'description': 'Has a little nice tail'
},
{
    'name': 'Muffy',
    'type': 'Cat',
    'imageUrl': 'https://placekitten.com/636/660',
    'description': 'Loves drinking milk'
},
{
    'name': 'Beth',
    'type': 'Cat',
    'imageUrl': 'https://placekitten.com/660/636',
    'description': 'Might give gentle bites when played with'
}]

module.exports = pets
Enter fullscreen mode Exit fullscreen mode

The snippet above is just dummy for the database because we want the application to always have some pets to display, even on the first run.

We can start the backend by running the following command in the backend directory:

$ node index.js
Enter fullscreen mode Exit fullscreen mode

To test the backend at this stage, you can use a REST client (like PostMan) to make requests to the endpoints.

Building the frontend

An easy way to create a Nuxt project is to use the template created by the team. We will install it into a folder called frontend as we mentioned before, so run the following command:

$ vue init nuxt/starter frontend
Enter fullscreen mode Exit fullscreen mode

Note: You need vue-cli to run the command above. If you don’t have it installed on your computer, you can run npm install -g @vue/cli to install it.

Once the command runs, you will be met with a prompt asking some questions. You can press the Return key to accept the default values as they will work just fine for this project. Now run the following commands:

$ cd frontend
$ npm install
Enter fullscreen mode Exit fullscreen mode

We will start the development server with this command:

$ npm run dev
Enter fullscreen mode Exit fullscreen mode

The server will start on the address http://localhost:3000 and you will see the nuxt template starter page:

nuxt project

To confirm its server-side rendering, you can view the page’s source on your browser and you will see that the content on the page is rendered on the server and not injected during run-time by client-side JavaScript.

Let’s make a few configurations by updating the nuxt.config.js file accordingly:

// ./nuxt.config.js

module.exports = {
  /*
   * Headers of the page
   */
  head: {
    titleTemplate: '%s | Adopt a pet today',
    // ...
    link: [
      // ...
      {
        rel: 'stylesheet',
        href: 'https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css'
      },
      { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300&display=swap' }
    ]
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

We just configured our project to dynamically update its title depending on the page we are on using the titleTemplate option. We will inject the titles dynamically by setting the title property on each page and layout in our application and the %s placeholder will be updated.

We also pulled in Bulma CSS to style our application using the link property.

It is worth mentioning that Nuxt uses vue-meta to update the headers of our application as we navigate through.

Extend the default layout

The Nuxt template we installed ships with a default layout. We will customize this layout and use it to serve all the pages and components we define for this application. Let’s replace the content of the layouts/default.vue file with the snippet below:

<!-- ./layouts/default.vue -->

<template>
  <div>
    <!-- begin navigation -->
    <nav class="navbar has-shadow" role="navigation" aria-label="main navigation">
      <div class="container">
        <div class="navbar-start">
          <nuxt-link to="/" class="navbar-item is-half">
            <img
              src="https://www.graphicsprings.com/filestorage/stencils/f6e5c06cad423f0f7e6cae51c7a41f37.svg"
              alt="Logo: an image of a doggy biting a juicy bone!"
              width="112"
              height="28"
            />
          </nuxt-link>
          <nuxt-link active-class="is-active" to="/" class="navbar-item is-tab" exact>Home</nuxt-link>
          <nuxt-link
            active-class="is-active"
            to="/pet/new"
            class="navbar-item is-tab"
            exact
          >Post your own pet 😎</nuxt-link>
        </div>
      </div>
    </nav>
    <!-- end navigation -->
    <!-- displays the page component -->
    <nuxt />
    <!-- begin footer -->
    <footer class="footer home-footer has-background-black">
      <div class="content has-text-centered">
        <p class="has-text-white">
          <strong class="has-text-white">Pet adoption website</strong> by
          <a href="https://github.com/Jordanirabor">Jordan</a>
        </p>
      </div>
    </footer>
    <!-- end footer -->
  </div>
</template>

<style>
.main-content {
  margin: 20px 0;
}
body {
  font-family: "Open Sans Condensed", sans-serif;
}
p {
  font-size: 22px;
}
.home-footer{
  margin-top: 20vh;
}
</style>
Enter fullscreen mode Exit fullscreen mode

In the custom layout above, we added a navigation header and used the <nuxt-link> to generate links to the pages we want to be able to route to:

  1. / routes to the homepage
  2. /pet/new routes to the page that allows users to upload new pets

The single <nuxt> component is responsible for rendering dynamic page content.

Creating the homepage

Nuxt makes routing easy for us by giving us the option of creating pages by adding single file components in the pages directory. In other words, every file in the pages directory becomes a route that can be visited.

Let’s create the homepage by replacing the code in the pages/index.vue file with the following snippet:

<!-- ./pages/index.vue -->

<template>
  <div>
    <section class="hero is-medium is-dark is-bold">
      <div class="hero-body">
        <div class="container">
          <h1 class="title">Adopt a new pet today!</h1>
          <h2
            class="subtitle"
          >You just might need a curious kitten to stare at you as you slap the keyboard tirelessly 😃</h2>
        </div>
      </div>
    </section>
  </div>
</template>

<script>
export default {
  head: {
    title: "Home"
  }
};
</script>
Enter fullscreen mode Exit fullscreen mode

In the snippet above, we defined some markup using Bulma CSS classes. In the script section, we specified a title to equal “Home” so that the titleTemplate we configured is updated before the page is rendered on the client-side.

We can start the development server (if it isn’t already running). Take a look at what the homepage currently looks like:



This looks good, now we want to fetch the available pets from the backend server, loop through them and display each one of them in the homepage. Let’s start by replacing the <template> of the pages/index.vue file with this updated version:

<!-- ./pages/index.vue -->

<template>
  <!-- begin header -->
  <div>
    <section class="hero is-medium is-dark is-bold">
      <div class="hero-body">
        <div class="container">
          <h1 class="title">Adopt a new pet today!</h1>
          <h2
            class="subtitle"
          >You just might need a curious kitten to stare at you as you slap the keyboard tirelessly 😃</h2>
        </div>
      </div>
    </section>
    <!-- end header -->
    <!-- begin main content -->
    <section class="main-content">
      <div class="container">
        <h1 class="title has-text-centered">Available pets</h1>
        <div class="columns is-multiline">
          <div class="column is-half" v-for="pet in pets" :key="pet._id">
            <div class="card">
              <header class="card-header">
                <p class="card-header-title is-centered">{{ pet.name }}</p>
              </header>
              <div class="card-content">
                <figure class="image is-3by2">
                  <img :src="`${pet.imageUrl}`" />
                </figure>
              </div>
              <footer class="card-footer">
                <nuxt-link :to="`/pet/${pet._id}`" class="card-footer-item">
                  <button class="button is-dark">Learn more about {{ pet.name }}</button>
                </nuxt-link>
              </footer>
            </div>
          </div>
        </div>
      </div>
    </section>
    <!-- end main content -->
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

We will also update the <script> section so it makes a request to the backend server and loads the pets data object before rendering the client-side:

<!-- ./pages/index.vue -->

<script>
export default {
  head: {
    title: "Home"
  },
  async asyncData(context) {
    try {
      return await fetch("http://localhost:9000/api/pets")
        .then(res => res.json())
        .then(data => {
          return { pets: data };
        });
    } catch (e) {
      console.error("SOMETHING WENT WRONG :" + e);
    }
  },
  data() {
    return {
      pets: []
    };
  }
};
</script>
Enter fullscreen mode Exit fullscreen mode

In the code above, we used the asyncData method to fetch the pets data (using the promise based fetch API) from the backend server. We use this method because it fetches data and renders it on the server-side before sending a response to the browser. After its successful retrieval of data from the backend server, the pets data object becomes accessible as a data property on the Vue object.

Now we can revisit our application and see the homepage pre-populated with our mock data from the backend server:



Note: remember to keep the Node backend server running so the mock data is available.

Build the dynamic single pet page

We want to be able to click on the button attached to each pet’s card component and be routed to a page that displays more information of that particular pet. How do we achieve this with Nuxt? Nuxt lets us add dynamic routes and we can access them with a URL like this: /pet/1.

To achieve this, we need to create a new directory in the pages folder called pet. We will then structure it like this:

pages
└── pet
    └── _id
        └── index.vue
Enter fullscreen mode Exit fullscreen mode

Structuring the directory hierarchy like this has the effect of generating dynamic routes with the following configuration:

router: {
  routes: [
    // ...
    {
      name: 'pet-id',
      path: '/pet/:id',
      component: 'pages/pet/_id/index.vue'
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Once the directory structure has been achieved, paste in the following code in the pages/pet/_id/index.vue file:

<!-- ./pages/pet/_id/index.vue -->

<template>
  <div class="main-content">
    <div class="container">
      <div class="card">
        <header class="card-header">
          <p class="card-header-title is-centered">{{ pet.name }}</p>
        </header>
        <div class="card-content has-background-dark">
          <figure class="image is-1by1">
            <img class :src="`${pet.imageUrl}`" />
          </figure>
        </div>
        <br />
        <h4 class="title is-5 is-marginless">
          <p class="has-text-centered">About</p>
          <hr />
          <p class="has-text-centered">
            <strong>{{ pet.description }}</strong>
          </p>
          <br />
        </h4>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  validate({ params }) {
    return /^[a-f\d]{24}$/i.test(params.id);
  },
  async asyncData({ params }) {
    try {
      let pet = await fetch(`http://localhost:9000/api/pet/${params.id}`)
        .then(res => res.json())
        .then(data => data);
      return { pet };
    } catch (e) {
      console.error("SOMETHING WENT WRONG :" + e);
      return { pet: {} };
    }
  },
  head() {
    return {
      title: this.pet.name,
      meta: [
        {
          hid: "description",
          name: "description",
          content: this.pet.description
        }
      ]
    };
  }
};
</script>
Enter fullscreen mode Exit fullscreen mode

In the <script> section above, we used a new method called validate(). We used this method to check that the route parameter passed is a valid Hexadecimal MongoDB ObjectId. In the case where the check fails, Nuxt will automatically reload the page as a 404 error.

We also used asyncData here to fetch the single pet object before rendering the page. On visiting our application again, it will look like this:

Uploading your pet

At this stage, it’s already fun to browse our application and see cute pet pictures, but what if we had a pet we want to put up for adoption? Let’s create a new file — pages/pet/new.vue — to implement this feature. Paste in the following code in the pages/pet/new.vue file:

<!-- pages/pet/new.vue -->  

<template>
  <div class="container">
    <br />
    <h1 class="title has-text-centered">{{pet.name}}</h1>
    <div class="columns is-multiline">
      <div class="column is-half">
        <form @submit.prevent="uploadPet">
          <div class="field">
            <label class="label">Name</label>
            <div class="control">
              <input
                class="input"
                type="text"
                placeholder="What is your pet's name?"
                v-model="pet.name"
              />
            </div>
          </div>
          <div class="field">
            <label class="label">Description</label>
            <div class="control">
              <textarea
                class="textarea"
                v-model="pet.description"
                placeholder="Describe your pet succintly"
              ></textarea>
            </div>
          </div>
          <div class="file">
            <label class="file-label">
              <input class="file-input" @change="onFileChange" type="file" name="resume" />
              <span class="file-cta">
                <span class="file-icon">
                  <i class="fas fa-upload"></i>
                </span>
                <span class="file-label">Upload a pet image…</span>
              </span>
            </label>
          </div>
          <br />
          <div class="field">
            <label class="label">Type of pet</label>
            <div class="control">
              <div class="select">
                <select v-model="pet.type">
                  <option value="Cat">Cat</option>
                  <option value="Dog">Dog</option>
                </select>
              </div>
            </div>
          </div>
          <div class="field is-grouped">
            <div class="control">
              <button class="button is-link">Submit</button>
            </div>
          </div>
        </form>
      </div>
      <div class="column is-half">
        <figure v-if="preview" class="image container is-256x256">
          <img
            style="border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);"
            :src="preview"
            alt
          />
        </figure>
        <figure v-else class="image container is-256x256">
          <img
            style="border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);"
            src="https://via.placeholder.com/150"
          />
        </figure>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  head() {
    return {
      title: "New Pet"
    };
  },
  data() {
    return {
      pet: {
        name: "",
        image: "",
        description: "",
        type: "Cat"
      },
      preview: ""
    };
  },
  methods: {
    onFileChange(e) {
      let files = e.target.files || e.dataTransfer.files;
      if (!files.length) {
        return;
      }
      this.pet.image = files[0];
      this.createImage(files[0]);
    },
    createImage(file) {
      let reader = new FileReader();
      let vm = this;
      reader.onload = e => {
        vm.preview = e.target.result;
      };
      reader.readAsDataURL(file);
    },
    async uploadPet() {
      let formData = new FormData();
      for (let data in this.pet) {
        formData.append(data, this.pet[data]);
      }
      try {
        let response = await fetch("http://localhost:9000/api/pet/new", {
          method: "post",
          body: formData
        });
        this.$router.push("/");
      } catch (e) {
        console.error(e);
      }
    }
  }
};
</script>
Enter fullscreen mode Exit fullscreen mode

In the code above, the uploadPet() method is an asynchronous method that posts a new pet object to the backend server and redirects back to the homepage on successful upload:

Hurray! This brings us to the end of the tutorial.

Conclusion

In this article, we learned about SPAs, their advantages and disadvantages. We also explored the concept of isomorphic applications and used Nuxt to build a pet adoption website that preloads data on the server-side before rendering the UI.

The source code for this tutorial is available on GitHub.


200's only ‎✅: Monitor failed and show GraphQL requests in production

While GraphQL has some features for debugging requests and responses, making sure GraphQL reliably serves resources to your production app is where things get tougher. If you’re interested in ensuring network requests to the backend or third party services are successful, try LogRocket.

Alt Text

LogRocket is like a DVR for web apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report on problematic GraphQL requests to quickly understand the root cause. In addition, you can track Apollo client state and inspect GraphQL queries' key-value pairs.

LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.


The post Build an isomorphic application with Nuxt.js and Node appeared first on LogRocket Blog.

Top comments (0)