Use Nuxt(Vue) and Amp to Create a Website

Use Nuxt(Vue) and Amp to Create a Website
In this article, we shall cover the challenges that I faced while building a website using AMP and Nuxt.js.

Developers often receive requests from clients that are not always logical or promote best practices; but even so, they often present an interesting chance to challenge ourselves. As we try to meet the requirements, we often face the challenge straight on without hesitation for completion.

Not long ago, I found myself in a similar situation with a client that requested for the AMP framework to be used halfway through a project. This decision might have been influenced by AMP’s benefits but was a major blow in the at-the-time ongoing development. Initially, the Vue.js framework, which heavily uses JavaScript was requested while AMP limits the use of CSS and forbids JavaScript in the project. Knowing that starting from scratch or changing to another SSR framework would put us at risk of not finishing the project on time, our team decided to go for Nuxt.js.Therefore we could use all of the already created components, views and logic while adding an SSR that will enable the usage of AMP.

In this article, we shall cover the challenges that I faced while building a website using AMP and Nuxt.js. This post is written assuming the reader already has knowledge of HTML, CSS, JavaScript, Vue.js and possibly Nuxt.js.

Disclaimer

This post is by no means a detailed tutorial nor promoting the best practice of how to combine these two frameworks. It was written as just a case study, to be used as an example or reference by others that might find themselves in similar circumstances, due to the lack of other examples when attempting the above.

What is Nuxt.js?

Nuxt.js is an open-source framework based on Vue.js. As described on the official website:

Its main scope is UI rendering while abstracting away the client/server distribution.

Our goal is to create a framework flexible enough that you can use it as a main project base or in addition to your current project based on Node.js.

Nuxt.js presets all the configuration needed to make your development of a server-rendered Vue.js Application more enjoyable.

In this article, Nuxt is used specifically due to its efficiency in developing an SSR Vue.js website.

What is Amp?

Accelerated Mobile Pages or AMP is an open-source framework backed by Google with a purpose to speed up how fast webpage content is displayed to mobile users, due to the increasing number people who browse the internet through their smartphones. Or as the official AMP website would say:

AMP is a simple and robust format to ensure your website is fast, user-first, and makes money. AMP provides long-term success for your web strategy with distribution across popular platforms and reduced operating and development costs.

In summary, AMP should increase the loading speed of your pages, help you attract and retain mobile traffic by improving your SEO ranking at the cost of having to abide by the strict rules of its implementations, such as:

  • no in-house or third-party Javascript
  • HTML tags restrictions
  • only inline custom style limited to 50KB and some additional CSS regulations

At first sight, AMP doesn’t look like an intuitive approach a developer would take. However, since there is a promise of an increased SEO ranking and especially since that promise comes from none other than Google itself, it is not unusual that AMP attracts the attention of clients who want nothing else but for their product to be discovered.

Let’s get started!

As showing parts of the actual project is, of course, off-limits, we decided to demonstrate some of the hurdles we encountered by creating a demo containing the landing page of a video streaming site (called Home Theater) as well as a listing page, that implements pagination.

Note:

For the creation of this demo, we made use of the OMDb’s (Open Movie Database) API, which is a RESTful web service to obtain movie information. To use this API, you need to create an account, after which you acquire an API key needed to obtain the information.

We started by initializing a default Nuxt.js project as stated in their guide, by running one of the following commands:

npx create-nuxt-app <project-name>

or

yarn create nuxt-app <project-name>

We decided to name our project nuxt-amp-home-theather and used the following options :

? Project name <project-name>
? Project description <project-description>
? Author name <your name>
? Choose the package manager <npm or yarn>
? Choose UI framework None
? Choose custom server framework Express
? Choose Nuxt.js modules Axios
? Choose linting tools ESLint, Prettier
? Choose test framework None
? Choose rendering mode Universal (SSR)

In our case we chose yarn as the package manager and therefore from here on all of the given commands will be using yarn. With this we got a default project that we can easily view by running:

yarn dev

Implementing AMP

After the above initialization, we need to do some tweaks to the nuxt.config.js to be able to use AMP. Fortunately, the Nuxt team has prepared an AMP example that can be found here:

nuxt/nuxt.js

As seen in the example, besides the given changes to the config file we also need to to add anapp.html file. But that’s not all. Because AMP only allows inline CSS and only one <style amp-custom> tag, we also need to solve the problem of the styles written in each component.

The above example solves this by moving all of the styles into one file — main.css, that is then injected in every page by Nuxt. Then, with the help of the render:route hook, the amp-custom attribute is added to the <style> tag. Although this is a good solution, it also has its drawbacks.
First, you will have to write all the style in one file, which doesn’t improve the developers’ experience, unless you use multiple files and import all in one main file. Furthermore, if you have multiple components, and if not all of them are used on every page, you will have an unused CSS in each of your pages. Not to mention AMP imposes a 50KB size restriction, therefore your merged CSS should not exceed that size.

For these reasons, we decided to try a different approach, which was to leave the CSS as it is, in each component. When Nuxt renders each site, it will include only the needed CSS and all we have to do is merge them all in the render:route hook. We can do so by removing the line that adds the amp-custom attribute to added CSS, and replace it with the following code:

amp-merge-css-191009.js

// Add amp-custom tag to added CSS and join all the CSS into one <style-tag>
  let styleConcat = ''
  html = html.replace(/(<style\b[^<>]*>)([^<]*)(<\/style>)/gi, (_match, p1, p2) => {
    styleConcat += p2
    return ''
  })

  html = html.replace('</head>', `<style amp-custom data-vue-ssr>${styleConcat}</style></head>`)

As can be seen in the code above, we’re removing every style tag and merging all of their contents. Afterward, we just add all the content into a single <style amp-custom> tag at the end of the <head> tag. It’s worth noting that the merged content is not minified, therefore you can use a library to minify it, to further reduce its size (e.g. clean-css).

The Home page

Unlike my previous post, where I kept the CSS to the bare minimum, I decided to spend some time and make the pages look a bit more decent. You can get the CSS from the finished project’s repo.

The content of the Home page is rather simple, containing just two parts: a banner and a short trending movie list. The content of the home page looks like this:

Mobile View of the Home Page
Mobile View of the Home Page

amp-home-theather-home.vue

<template>
  <div class="container">
    <banner :movies="movies" />

    <div class="content-wrapper">
      <h1 class="title">Trending contents</h1>

      <movie-list :movies="movies" />
    </div>
  </div>
</template>

We’re passing the list of movies obtained from the OMDb’s API to both the bannerand the movie-list component. The latter is also rather simple, just a list of images displayed as a gird. Since we’re displaying an image, we have to use the amp-img tag as instructed by AMP:

amp-home-theather-movie-list.vue

<template>
  <div class="movie-list">
    <div v-for="(movie, index) in movies" :key="`movie-poster-${index}`" class="movie">
      <amp-img :src="movie.Poster" width="197" height="310" layout="responsive" alt="a sample poster" />
    </div>
  </div>
</template>

The banner component, on the other hand, displays just the logos of the app, Nuxt, and AMP, as well as a carousel displaying the posters of the movies. As you can notice, we’re using the amp-carousel component to display the carousel as custom JavaScript is not allowed.

amp-home-theather-banner.vue

<template>
  <div class="row banner">
    <div class="title-logo">
      <amp-img width="1048" height="200" layout="responsive" :src="appLogo" />
    </div>
    <div class="nuxt-logo">
      <amp-img width="300" height="220" layout="responsive" :src="nuxtLogo" />
    </div>
    <div class="amp-logo">
      <amp-img width="256" height="256" layout="responsive" :src="ampLogo" />
    </div>
    <div class="banner-carousel">
      <amp-carousel width="197" height="310" layout="responsive" type="slides" autoplay delay="2000">
        <amp-img
          v-for="(movie, index) in movies"
          :key="`movie-poster-${index}`"
          :src="movie.Poster"
          width="197"
          height="310"
          layout="responsive"
          alt="a sample poster"
        />
      </amp-carousel>
    </div>
  </div>
</template>

With this our Home page is complete, however, if we try to display it and add #development=1 at the end of the URL, we’ll see an error regarding the autoplay attribute of the carousel element. The problem is that Nuxt renders this attribute with the value: autoplay="autoplay", while AMP strongly requests it to have either no value for infinite loop or a number to specify the number of loops after which the autoplay will stop.
After some browsing, we couldn’t find a way how to disable this automatic value assignment, and therefore proceeded to solve it by adding the following line in themodifyHtml method in the config file:

html = html.replace(/<amp-carousel([^>]*)>/gi, match => {
  return match.replace('autoplay="autoplay"', 'autoplay')
})

The Series page

The second page contains just a list of movies, along with a pagination component to move through the pages.

Mobile View of the Series Page
Mobile View of the Series Page

amp-home-theather-series.vue

<template>
  <div class="container">
    <div class="content-wrapper">
      <h1 class="title">All contents</h1>

      <div class="pagination-wrapper">
        <pagination :current-page="currentPage" :total-items="totalItems" :rows-per-page="rowsPerPage" />
      </div>

      <amp-list
        src="https://www.omdbapi.com/?apikey=7f517297&type=movie&s=man&page=1"
        [src]="'https://www.omdbapi.com/?apikey=7f517297&type=movie&s=man&page=' + currentPage"
        items="Search"
        width="auto"
        height="570"
        layout="fixed-height"
        class="movie-list"
        v-html="listTemplate"
      />
    </div>
  </div>
</template>

Unlike the Home page, where the list was rendered just once on the server-side to be presented to the user, here we need to update the list as we go through the pages. For that reason, we are using the amp-list component along with a custom pagination component.
By reading the AMP documentation about this component, it is evident that it is more convenient for an infinite scroll, rather than paginated display. However, since it requires the JSON response to contain the URL of the next page, and the OMDb API doesn’t, we cannot implement the infinite scroll. Instead, we have to manually change the URL usingamp-bind. This functionality allows you to change the values of attributes similarly like Vue.
To achieve that AMP enables you to maintain a variable using the amp-state component since JavaScript is not allowed.

In our case, we modify the URL with the help of the currentPage state (variable) that we maintain in the pagination component.

amp-home-theather-pagination.vue

<template>
  <div class="pagination">
    <amp-state id="rowsPerPage">
      <script type="application/json">
        {{ rowsPerPage }}
      </script>
    </amp-state>
    <amp-state id="totalItems">
      <script type="application/json">
        {{ totalItems }}
      </script>
    </amp-state>
    <amp-state id="currentPage">
      <script type="application/json">
        {{ currentPage }}
      </script>
    </amp-state>
    <amp-state id="totalPages">
      <script type="application/json">
        {{ totalPages }}
      </script>
    </amp-state>
    <amp-state id="startItem">
      <script type="application/json">
        {{ startItem }}
      </script>
    </amp-state>
    <amp-state id="endItem">
      <script type="application/json">
        {{ endItem }}
      </script>
    </amp-state>

    <div class="info">
      <span [text]="'Showing ' + startItem + ' - ' + endItem + ' out of ' + totalItems + ' movies'">
        Showing {{ startItem }} - {{ endItem }} out of {{ totalItems }} movies
      </span>
    </div>
    <div class="controls">
      <div
        class="button"
        :class="{ 'is-disabled': currentPage === 1 }"
        [class]="currentPage == 1 ? 'button is-disabled' : 'button'"
        on="tap:AMP.setState({ currentPage: currentPage - 1, startItem: startItem - rowsPerPage, endItem: endItem - rowsPerPage })"
        role="button"
        tabindex="5"
      >
        <fa-icon class="icon" :icon="['fas', 'chevron-left']" />
      </div>
      <div class="button">
        <div class="button-text" [text]="currentPage">
          {{ currentPage }}
        </div>
      </div>
      <div
        class="button"
        :class="{ 'is-disabled': currentPage === totalPages }"
        [class]="currentPage == totalPages ? 'button is-disabled' : 'button'"
        on="tap:AMP.setState({ currentPage: currentPage + 1 , startItem: startItem + rowsPerPage, endItem: totalItems < endItem + rowsPerPage ? totalItems : endItem + rowsPerPage })"
        role="button"
        tabindex="5"
      >
        <fa-icon class="icon" :icon="['fas', 'chevron-right']" />
      </div>
    </div>
  </div>
</template>

Stimulating the pagination component, without being able to use JavaScript, required using several amp-state components, and updating each with the on:"tap:AMP.setState()" method.
We need to initialize the multiple states, and that can only be done by either providing an API URL or using a <script type="application/json"> tag. Since we’re using the states as variables for the pagination, we need to use the script tag. That presents a problem as we previously set up the modifyHtml render function to remove all scripts tags. Hence, we need to modify that part to exclude the type="application/json" tags.

After the modification, the final version of the modifyHtml function should look like this:

amp-home-theather-modifyHtml.js

const ampBoilerplate =
  '<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>'

const modifyHtml = html => {
  // Add amp-custom tag to added CSS and join all the CSS into one <style-tag>
  let styleConcat = ''
  html = html.replace(/(<style\b[^<>]*>)([^<]*)(<\/style>)/gi, (_match, p1, p2) => {
    styleConcat += p2
    return ''
  })

  html = html.replace('</head>', `<style amp-custom data-vue-ssr>${styleConcat}</style></head>`)

  // Remove every script tag from generated HTML except the JSON type for the amp-state or the AMP templates
  html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, match => {
    if (match.includes(`type="application/json"`)) {
      return match.replace(/&quot;/g, '"')
    }

    return ''
  })

  // Add AMP script before </head>
  const ampScript = `<script async src="https://cdn.ampproject.org/v0.js"></script>
  <script async custom-element="amp-carousel" src="https://cdn.ampproject.org/v0/amp-carousel-0.1.js"></script>
  <script async custom-element="amp-bind" src="https://cdn.ampproject.org/v0/amp-bind-0.1.js"></script>
  <script async custom-element="amp-list" src="https://cdn.ampproject.org/v0/amp-list-0.1.js"></script>
  <script async custom-template="amp-mustache" src="https://cdn.ampproject.org/v0/amp-mustache-0.2.js"></script>`

  html = html.replace('</head>', ampScript + ampBoilerplate + '</head>')

  html = html.replace(/<amp-carousel([^>]*)>/gi, match => {
    return match.replace('autoplay="autoplay"', 'autoplay')
  })

  return html
}

You may notice that we’re also adding several scripts right after removing the already existing ones. These are the mandatory scripts that need to be included to be able to use the various AMP components.

With this, our demo project is complete and can be deployed. For this blog post, we used Heroku, but it is up to the developer to chose which platform works best for them. As the deployment of a Nuxt project is not the target of this post, we shall not get in any details regarding this subject.

You can visit our deployed demo here, and check out the project’s repo here.

Conclusion

By demonstrating the above project, we can see that it is possible to combine Nuxt (Vue) and AMP. This combination comes at the coast of the page initial load time as we need to adjust the Nuxt generated HTML to be suitable for AMP. After the initial load, the pages should be cached, and thus any subsequential load should be faster.

However, since Nuxt is a JavaScript dependent framework, while AMP requires all other JavaScript to be removed, I believe using both is a bit counterintuitive. Therefore, I would advise to carefully think about the purpose and potential goals of a project before you start its development so that the right framework can be chosen from the very start.

Nevertheless, the direction of a project is not always clear, nor does it always stays the same. And so, I hope this post will be of some help to those who might find themselves in a situation similar to ours.

Suggest:

Nuxt.js - Practical NuxtJS

Generating AMP Pages with JavaScript and Vue.js

Vue js Tutorial Zero to Hero || Brief Overview about Vue.js || Learn VueJS 2023 || JS Framework

Learn Vue.js from scratch 2018

Is Vue.js 3.0 Breaking Vue? Vue 3.0 Preview!

Vue.js Tutorial: Zero to Sixty