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:
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.
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.
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:
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:
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:
Let’s get started.
You’ll need the following for this tutorial:
For reference, the source code for this tutorial is available on GitHub.
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
Let’s create the backend
folder within the project directory:
$ mkdir backend $ cd backend
The first thing we want to do is to initialize a new npm project:
$ npm init -y
Let’s install Nodemon to help us automatically refresh our server when we make code changes:
$ npm install nodemon -g
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
Let’s create the following folder structure in the backend
directory:
backend └── /models └── pet.js └── /routes └── api.js └── index.js └── mock.js
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);
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:
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;
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:
/pet/new
handles the upload of new pet objects/pet/:_id
finds and returns an existing pet to be rendered on the client-side/pets
returns all petsFinally, 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) }) })
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) }) })
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
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
To test the backend at this stage, you can use a REST client (like PostMan) to make requests to the endpoints.
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
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
We will start the development server with this command:
$ npm run dev
The server will start on the address http://localhost:3000 and you will see the nuxt template starter page:
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/[email protected]/css/bulma.min.css' }, { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300&display=swap' } ] }, // ... }
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.
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>
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:
/
routes to the homepage/pet/new
routes to the page that allows users to upload new petsThe single <nuxt>
component is responsible for rendering dynamic page content.
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>
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>
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>
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.
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
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' } ] }
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>
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:
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>
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.
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.
Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third-party services are successful, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.
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.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowDesign React Native UIs that look great on any device by using adaptive layouts, responsive scaling, and platform-specific tools.
Angular’s two-way data binding has evolved with signals, offering improved performance, simpler syntax, and better type inference.
Fix sticky positioning issues in CSS, from missing offsets to overflow conflicts in flex, grid, and container height constraints.
From basic syntax and advanced techniques to practical applications and error handling, here’s how to use node-cron.