Why Nuxt.js is the perfect framework for building static websites


Have you ever had client requirements where you had to build highly interactive, animation-heavy websites with good performance? What if this interactive experience needed to act like a full-blown website and also provide benefits such as individual URLs for each page, sitemap, accessibility, multiple-language and SEO support, to name a few?

This article will walk you through a case-study of a project that’s built with Nuxt.js, Tailwind CSS, and the GreenSock Animation Platform. Hopefully the article will help inspire you to kick-start your next idea to achieve all of the above and more!

The project discussed here is loosely base on this beautiful site, built with React, called Build Your Best Day. I have tried to build most of the key interactions that I thought would be good for learning and fun to implement with the combination of Nuxt and Vue. Before we dive in, take a look at the demo and here’s the Github repo.

First let’s understand why we’re using Nuxt.js, and how we can leverage the goodness of Vue.js along the way. We’ll then briefly cover Lighthouse Audit results and the transitions & animations created using CSS and GreenSock to take the website to the next level.

*Nature illustration used as a backdrop is designed by Adrianne Walujo for MixKit Art. I have erased the sky, the sun and the clouds from the original artwork to add my own HTML-based sky and sun for animation purposes.*

Why Nuxt?

So why are we using Nuxt for this project? Let’s take a close look at layouts, pages and lifecycle hooks.


Layout is the centrepiece of this project, which is responsible for keeping all common parts of the navigation in one place. I have sketched up the following diagram to divide the layout into smaller Vue components.

Visual presentation of Vue components on default layout Visual presentation of Vue components on default layout

We can see how the navigation takes up an entire viewport! And it’s supercharged with six key elements:

  1. Gradients — Includes natural scene background & sun ☀️
  2. SVG Arc — Custom loading indicator
  3. Mega-menu — Groups custom elements such as animated title, subtopics menu, auto-playing tips components
  4. Page-navigation — back and next buttons
  5. Navbar — Groups navigation items such as language switcher, page indicators and close button
  6. Nuxt-shape — SVG shape filler animation

Keeping these components on layout help us get that continuous feel of animation and helps us show, hide, slide, and fade different elements depending on the application state.

Let’s see how they actually fit onto default layout component.


  <div class="default">

    <gradient-background />

    <arc />

    <mega-menu />

    <!-- TOPIC CONTENT - DISPLAYED THROUGH pages/_id.vue -->
      <nuxt />

      <page-navigation />
      <!-- 5. NAVBAR -->
      <nav-bar />

    <!-- 6. NUXT SHAPE -->
      <nuxt-shape />


All of these components are grouped together at /components/layout/ and then placed on default layout component at /layouts/default.vue.

Dynamic Nested Routes

Unlike a typical Vue application, we don’t have to manually configure routes in Nuxt. Rather, we simply create the directory structure under /pages. The page structure is divided between base and dynamic routes.

We have three base routes:

  • index
  • introduction
  • summary

And the dynamic routes are generated by set of data  that is an array of objects. See pages directory of the final project here to gain better understanding for this section.

Below is the basic structure of one object, with only absolutely necessary keys. As we progressively create the application, we can always add more key-values to this data structure.


   "slug": "nuxt-server-init",
   "title": "Nuxt Server Init",
   "points": [
        "id": "point-1-1",
        "title": "1.1"
        "id": "point-1-2",
        "title": "1.2"
        "id": "point-1-3",
        "title": "1.3"

As seen in the object above, our dynamic parent routes will have dynamic children to display the content for each sub-topic/point.

Meaning, first dynamic level route, localhost:3000/_slug will make the second level routes work. Like this, localhost:3000/_slug/_id

We have used the dynamic nested route option to generate such routes. The Pages directory results in the following structure.


See full version of the JSON data in /static directory that helps us creating dynamic nested routes as shown below.

Using Lifecycle Hooks

Now, how do we set this JSON data in the store?

Pre-fill store with nuxtServerInit

Nuxt provides couple of ways to do this. We can either use asyncData or fetch to set the data, or use nuxtServerInit to do the same.

We need this data to be called in just once, and pre-fill the store. So, asyncData or fetch may not be the right fit for this task, as they’re called every time the page loads on both, client and server-side.

On the other hand, nuxtServerInit is called on server-side, only once! Since it’s one of the first hooks to be called, it’s perfect for setting initial data into store.

nuxtServerInit (vuexCtx, nuxtCtx) {   
    vuexCtx.commit("SET_PAGES", nuxtCtx.app.i18n.t("pages"));

nuxtServerInit receives Vuex context as the first argument, and Nuxt context as a second. I have used Vuex context to commit that pages data into state, so that they’re available even before the application is ready.

pages are set in Vuex store because they’ve application wide usage. But if you think about the (markdown) content of sub-topics. They don’t need to be set globally, instead we should be able to import the markdown file, on topic by topic basis, when the page is navigated to.

This is when asyncData comes in handy!

Set data with asyncData

asyncData is called every time page loads, on server-side when the first HTTP request is made. And then every time the page is navigated to on client-side.

async asyncData({ params, app }) {
   let content = await importMd(params.id);        
   return { content };

Taking advantage of that behaviour, asyncData() method is used to load the markdown files for second-level dynamic routes that are powered through _id param.

Since we’re dealing with not one, but two dynamic parameters in our routes, we need to cover the scenario of what should happen when one of these params is incorrect.

Nuxt provides a special lifecycle hook just for this scenario, validate().

Error Handling with validate()

Validate hook validates parameter/s of the dynamic route and returns trueor false. validate() method is used on both of our dynamic routes to validate _slug and _id.


validate({ params, store }) {
    return store.state.pages.find(page => page.slug === params.slug);


validate({ params, store, app }) {
    return store.getters.activePage.points.find(
        point => point.slug === params.id

validate() must return true in order to proceed further. But if it returns false, user is redirected to the error page.That’s why we’ve made sure to create /layouts/error.vue for proper error handling.

You can review both dynamic pages to see the validate() in action.

Take a look at the component tree below to learn how pages and layouts use building block components.

Visualization of three key directories: pages, components and layouts Visualization of three key directories: pages, components and layouts

Directory Structure

Creating directory structure is no-brainer when you build with Nuxt.

Other than /pages, you can remove the directories that’s not required in your project and add your own directories if required, such as /config, /lang and /utils in this case.

  • /utils stores collection of JS utility functions, and
  • /lang exports language objects depending on number of languages defined in i18n configuration. Let’s take a look at /config directory where i18n configuration is setup.

Application Configuration

/config directory in fact holds all the configuration items that never change. They’re the collection of variables that are used to configure the application.

Remember, all of the config items are eventually required in nuxt.config.js.

For example,

  1. i18n configuration - We’re using nuxt-i18n module to enable second language, Hindi, for this project. So, we configure this module in /config/i18n.js that exports I18N object.

  2. Tailwind config - We’ve pretty heavy tailwind configuration file, specially in terms of inset ( top and left values) and spacing of elements with absolute position!

  3. pointsMap - This is a collection of yPercent values of circles position on the SVG arc. These values are then used in custom Vue transition titled RotateInOut.

  4. icons - An array of material icons used for page indicators. In case, different icons are requested in future, we simply change this configuration array.

Vue Meta

Nuxt provides powerful head() method on page components to take care of creating unique title tag for each pages of the site. This feature is super important for SEO reasons.

/pages/index.vue can use the default meta defined at nuxt.config.js But the dynamic pages presents real use-case.

📃pages/_slug/_id.vue & 📃pages/_slug/index.vue

head() {
  return {
    title: this.title,
    meta: [
          hid: "description",
          name: "description",
          content: this.title

In above code, this.title is a computed property that’s responsible to create dynamic title for each pages depending on where they are in the hierarchy.

Different titles created using Vue meta Different titles created using Vue meta

Make sure to checkout both of these page components on Github to see the complete example with computed properties.

Custom Loader Indicator

This intimidating custom loading bar seems difficult to implement at first. But Nuxt makes it pretty easy to customise the default loader bar to make it truly yours. All we need to do is create custom component at components/loading.vue.

I have turned this component into a functional component that updates global state variable loading of Boolean type. After this, we can use v-if directive or CSS style binding to apply desired transition.

And finally, we link this component into nuxt.config.js under loading key.

Custom loader indicator Custom loader indicator

In this example, we’ve used CSS style (with keyframe animation) binding to path element as shown below.

📃 components/layout/Arc.default.vue

<path :class="{ 'loading-bar': loading }">...</path>

loading-bar class animates stroke-dasharray attribute of a path to give the drawing effect.

Nuxt Generate

To create static site with Nuxt, we use nuxt generate command to pre-render all static pages upfront before deploying.

Base routes are automatically generated for all the locales that are defined in i18n configuration. It’s the dynamic nested routes that we need to write a custom script for. In this case, this custom script is required to generate same set of routes for Hindi language as well.

📃 utils/routeHelper.js

let dynamicRoute = (lang = "") => {
  return new Promise(resolve => {
    const prepend = lang === "" ? "" : `/${lang}`;
    const firstLevelRoutes = data.map(el => `${prepend}/${el.slug}`);
    const secondLevelRoutes = data.map(el =>
      el.points.map(t => `${prepend}/${el.slug}/${t.slug}`)

It’s important to note that this same script will help us setup the sitemap as well. You can refer to the Sitemap generated with this script.

Special thanks to Shirish Nigam for helping out with the script above and thanks to Sarah Drasner for her write-up on Creating Dynamic Routes in Nuxt Applications.

Animations, transitions and CSS

There are two ways to navigate the site. 1) via the Back and Next buttons seen on left and right side of the view-port, and 2) via the indicators located in the header section.

Sub-topic menu for each of the four dynamic routes Sub-topic menu for each of the four dynamic routes

Now, each page has its own sub-menu that is visible in the sets of three, four or five circles in the centre of the page (example above).

You may wonder how are the circles positioned right on top of the arc! Well, this effect is achieved using custom Vue transition, RotateInOut, which uses yPercent values for defined in pointsMap configuration file.

So, if you think about it, this is very static in nature. Meaning, if you want to display six or seven circles on this arc instead, then you’ll have to find respective yPercent values and feed into the custom transitions. Of course, the circle size would be lot smaller if we’re to fit more of them!

Site has many other little components that slides and fades as a result of user interactions. Such as,

Data driven SVG Shape filler animation Data driven SVG Shape filler animation

  • Slideshow that animates lines and letters on Introduction page

Title characters animation and lines animation using custom Vue Js Transitions Title characters animation and lines animation using custom Vue Js Transitions

  • Moving sun ☀️ and its halo in the layout that’s reactive to user interaction and act like a progress indicators of the user-journey within the site.

Sun moves from morning to night Sun moves from morning to night

These beautifully moving elements and their behaviour is controlled by CSS keyframe animations combined with,

  • v-if directive & CSS Transitions and
  • style-binding.

We can use Tailwind CSS here because the layout of this interactive design only partially fit into traditional grid-system. We’ve put together all the CSS keyframe animations together at assets/css/keyframe.animation.css

We fallback on GreenSock animation library when CSS3 is inadequate to get results. For example, adjusting sub-menu circles on the SVG arc or breaking up text into individual characters and animating them.

Custom function written  to break title letters, is unable to animate characters of Hindi language (Devanagari Script) at the moment. You may choose to use TextPlugin by GreenSock as an alternative. With it, you’ll gain an added benefit to animate special characters, as well as easily animate lines, words or characters with only a few lines of code.

The combination of custom Vue transitions and GSAP is very powerful. All custom Vue transitions are grouped together at components/transitions.


Nuxt is very light-weight by default. But we can selectively give it more power to do extra stuff for our application by using modules, like the ones we have used below in this project.

  • nuxt-i18n — …enabled adding content in two languages with very minimal configurations in Nuxt environment
  • PWA — …helped support key PWA features, such as, — Registering Service Worker Js — Prompt to install the web app on mobile devices — Customising Workbox-build to cache assets
  • markdownit — …enabled writing content in simple markdown files and render them in Vue component using v-html directive
  • Sitemap —  enabled sitemap.xml generation on the fly during npm generate
  • Google Analytics — …enabled adding Google analytics code for tracking

After all of these dependencies and HTML content, I was able to get the following results on Google Lighthouse Auditing tool.

Lighthouse Score

The Performance score has been fluctuating throughout the development journey, and it has fallen in the range between 86 and 99 depending on the various conditions. When I turned off Clear storage checkbox in Audit panel, it had even hit 100!

That’s why, I’d suggest following tips & tricks below to get as best results as possible.

Test after,

  • turning off unused Chrome extensions
  • closing all open tabs except for your Nuxt project
  • deploying the app online — preferably on Heroku or Netlify

Know that the result may vary depending on different versions of Chrome. Lighthouse isn’t the ultimate auditing tool, but in my experience, the more your website comply to Lighthouse, the better it performs.


Most of us know Nuxt as an SSR framework, built on top of Vue that helps rendering webpages on server-side to provide SEO benefits. But Nuxt can be equally powerful with modules, plugins and page-based routing system to create this kind of static experience that uses only a handful of options.

This type of website can be used as a landing page, microsite or even as an eLearning shell that’s highly engaging and fun to browse with some amounts of surprising factors. And it could very well result into visitors spending more time on the site and even help them retain the information delivered using unique layout!

Without a doubt, there’s lot of room for improvements here. I already have a few on my list.

  • Streamline all GSAP animations and convert them into Nuxt plugin for re-using them in more than one project.
  • The site works fine on tablet and desktop. It’s not optimised for mobile devices at the time of writing this. It may require a complete re-design for the mobile layout, since the vertical orientation could be an issue, for example, on Route Middleware page where I have five sub-topics!
  • I’m planning to add moving clouds and stars to the natural scene in the background, it’d be interesting to see the performance after that.

So… follow me on Twitter for future updates and versions of this project. Thank you so much for reading about my work. ❤ I hope you learned something from my Nuxt experiment. And last but not least, Are you Nuxt?

Download the cheatsheets

Save time and energy with our cheat sheets.