An (almost) comprehensive guide on using Storybook with Nuxt.js

Lawrence Braun
Apr 5 · 15 min read


Content


Initial setup

Because this guide is from the ground up, we’re starting with a fresh Nuxt project using create-nuxt-app:

Build error?

At the time of writing this article, upgrading to Nuxt 2.5 results in an error when building:

ERROR  Failed to compile with 1 errors                                                                                                                                          friendly-errors 13:29:07
[...]
Module parse failed: Unexpected token (7:24)                                                                                                                                     friendly-errors 13:29:07
[...]
| 
| var _0c687956 = function _0c687956() {
>   return interopDefault(import('../pages/index.vue'
|   /* webpackChunkName: "pages/index" */
|   ));
rm -rf node_modules package-lock.json
npm i -D webpack@4.28.4
npm i

Adding Storybook

We’ll install Storybook and necessary dependencies manually according to their guidelines for Vue. Most dependencies are already present due to Nuxt, with babel-preset-vue being the only one missing.

// Add Storybook & dependencies
npm i -D @storybook/vue babel-preset-vue
// /.storybook/config.jsimport { configure } from '@storybook/vue';function loadStories() {
  const req = require.context('../stories', true, /\.stories\.js$/);
  req.keys().forEach(filename => req(filename));
}configure(loadStories, module);

Adding our first Story

Inside your components directory, create a new folder named list and within it a file named List.vue with the code below. We’ll use it to build our final component as we go.

// /components/list/List.vue<template>
  <div class="list">
    I'm a list
  </div>
</template><script>
  export default {
    name: 'List'
  }
</script><style scoped>
  .list {
    background: #CCC;
  }
</style>
// /components/list/List.stories.jsimport Vue from 'vue'
import { storiesOf } from '@storybook/vue'
import List from './List'storiesOf('List', module)
  .add('As a component', () => ({
    components: { List },
    template: '<List />'
  }))
  .add('I don\'t work', () => '<List />')
“storybook”: “start-storybook”
Our first stories! But only one works?
// /.storybook/config.jsimport { configure } from '@storybook/vue';import Vue from 'vue'
import List from '../components/list/List.vue'Vue.component('List', List)function loadStories() {
  const req = require.context('../components', true, /\.stories\.js$/);
  req.keys().forEach(filename => req(filename));
}configure(loadStories, module);

Enhancing our List component & Adding the Store

First off we’ll add some complexity to our List and worry with the errors Storybook throws at us later.

  • iterate each user/comment and render it using a ListItem component;
  • make use of Vuex to dispatch our API calls;
  • look prettier, using TailwindCSS & some custom styles;

Styles

For the styling we’ll use some TailwindCSS utility classes as well as some custom styles to exemplify its usage with Storybook. I use SCSS so we’ll need to add the usual node-sass & sass-loader:

npm i -D node-sass sass-loader
// /components/list/List.vue<template>
  <div class="list p-5 rounded">
    I'm a {{ source }} list
  </div>
</template><script>
  export default {
    name: 'List',
    props: {
      source: {
        type: String,
        default: 'users'
      }
    },
    data() {
      return {
        entities: []
      }
    },
    mounted() {
      switch (this.source) {
        default:
        case 'users':
          this.loadUsers()
          break        case 'comments':
          this.loadComments()
          break
      }
    },
    methods: {
      loadUsers() {
        //  Will call store action
        console.log('load users')
      },
      loadComments() {
        //  Will call store action
        console.log('load comments')
      },
    }
  }
</script><style lang="scss" scoped>
  $background: #EFF8FF;  .list {
    background: $background;
  }
</style>

Adding the Store & API calls

I usually keep my API calls in the Store’s actions so I can easily call them using this.$store.dispatch.

USERS_ENDPOINT=https://jsonplaceholder.typicode.com/users
COMMENTS_ENDPOINT=https://jsonplaceholder.typicode.com/comments
// /store/actions.jsexport default {
  async GET_USERS({ }) {
    return await this.$axios.$get(`${ process.env.USERS_ENDPOINT }`)
  },
  async GET_COMMENTS({ }) {
    return await this.$axios.$get(`${ process.env.COMMENTS_ENDPOINT }`)
  },
}
// /components/list/List.vue<template>
  <div class="list p-5 rounded">
    I'm a {{ source }} list
  </div>
</template><script>
  export default {
    name: 'List',
    props: {
      source: {
        type: String,
        default: 'users'
      }
    },
    data() {
      return {
        entities: []
      }
    },
    mounted() {
      switch (this.source) {
        default:
        case 'users':
          this.loadUsers()
          break        case 'comments':
          this.loadUsers()
          break
      }
    },
    methods: {
      loadUsers() {
        this.$store.dispatch('GET_USERS')
        .then(res => {
          console.log(res)
        })
        .catch(err => {
          console.log('API error')
          console.log(err)
        })
      },
      loadComments() {
        this.$store.dispatch('GET_COMMENTS')
        .then(res => {
          console.log(res)
        })
        .catch(err => {
          console.log('API error')
          console.log(err)
        })
      },
    }
  }
</script><style lang="scss" scoped>
  // Pointless. Just for the sake of the example
  $background: #EFF8FF;
  .list {
    background: $background;
  }
</style>

Adding ListItem component

Depending on whether we are listing Users or Comments, we’ll display a variation of the ListItem component. Each variation will have its own component too.

// /components/list/items/ListItem.vue<template>
  <div class="list-item rounded bg-blue-light px-5 py-3">
    <div v-if="itemType === 'users'">
      A user item
    </div>
    <div v-else>
      A comment item
    </div>
  </div>
</template><script>
  export default {
    name: 'ListItem',
    props: {
      itemType: {
        type: String,
        default: 'user'
      },
      data: {
        type: Object,
        default: () => {
          return {}
        }
      }
    }
  }
</script>
Homepage displaying 2 lists side-by-side

Adding a User & Comment component

We’ll create a component for each entity, based on the following data structure:

// User 
{
  "id": 1,
  "name": "Leanne Graham",
  "username": "Bret",
  "email": "Sincere@april.biz",
  "address": {
    "street": "Kulas Light",
    "suite": "Apt. 556",
    "city": "Gwenborough",
    "zipcode": "92998-3874",
    "geo": {
      "lat": "-37.3159",
      "lng": "81.1496"
    }
  },
  "phone": "1-770-736-8031 x56442",
  "website": "hildegard.org",
  "company": {
    "name": "Romaguera-Crona",
    "catchPhrase": "Multi-layered client-server neural-net",
    "bs": "harness real-time e-markets"
  }
}// Comment
{
  "postId": 1,
  "id": 1,
  "name": "id labore ex et quam laborum",
  "email": "Eliseo@gardner.biz",
  "body": "laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium"
}
// /components/list/items/Comment.vue<template>
  <div>
    <b>{{ name }}</b>
    <p>{{ body }}</p>
  </div>
</template><script>
  export default {
    name: 'Comment',
    props: {
      name: {
        type: String,
        default: ''
      },
      body: {
        type: String,
        default: ''
      }
    }
  }
</script>
// /components/list/items/User.vue<template>
  <div>
   <nuxt-link
      :to="{ name:'user' }"
      class="text-lg"
    >
      {{ name }} - "{{ username }}"
    </nuxt-link>
    <div class="flex flex-wrap justify-start my-2">
      <div class="w-1/2 mb-2">
        <span class="text-grey-dark font-bold">Email</span>
        <p class="p-0 m-0">{{ email }}</p>
      </div>
      <div class="w-1/2 mb-2">
        <span class="text-grey-dark font-bold">Phone</span>
        <p class="p-0 m-0">{{ phone }}</p>
      </div>
      <div class="w-1/2 mb-2">
        <span class="text-grey-dark font-bold">City</span>
        <p class="p-0 m-0">{{ address.city }}</p>
      </div>
      <div class="w-1/2 mb-2">
        <span class="text-grey-dark font-bold">Company</span>
        <p class="p-0 m-0">{{ company.name }}</p>
      </div>
    </div>
  </div>
</template><script>
  export default {
    name: 'User',
    props: {
      name: {
        type: String,
        default: ''
      },
      username: {
        type: String,
        default: ''
      },
      email: {
        type: String,
        default: ''
      },
      phone: {
        type: String,
        default: ''
      },
      address: {
        type: Object,
        default: () => {
          return {}
        }
      },
      company: {
        type: Object,
        default: () => {
          return {}
        }
      }
    }
  }
</script>
// /components/list/items/ListItem.vue<template>
  <div class="list-item rounded bg-indigo-lightest shadow px-5 py-3 mb-3">
    <div v-if="itemType === 'users'">
      <User
        :name="data.name"
        :username="data.username"
        :email="data.email"
        :phone="data.phone"
        :address="data.address"
        :company="data.company"
      />
    </div>
    <div v-else>
      <Comment
        :name="data.name"
        :body="data.body"
      />
    </div>
  </div>
</template><script>
  import User from '@/components/list/items/User'
  import Comment from '@/components/list/items/Comment'export default {
    name: 'ListItem',
    components: {
      User,
      Comment
    },
    props: {
      itemType: {
        type: String,
        default: 'user'
      },
      data: {
        type: Object,
        default: () => {
          return {}
        }
      }
    }
  }
</script>
// /components/list/List.vue
[...]
methods: {
  loadUsers() {
    this.$store.dispatch('GET_USERS')
    .then(res => {
      this.entities = res.data
    })
    .catch(err => {
      console.log('API error')
      console.log(err)
    })
  },
  loadComments() {
    this.$store.dispatch('GET_COMMENTS')
    .then(res => {
      this.entities = res.data
    })
    .catch(err => {
      console.log('API error')
      console.log(err)
    })
  },
}
[...]
Amazing Design®

Resolving Storybook’s complaints

We’ll now iron out each of the raised issues when running Storybook, the first one being:

Module not found

// /.storybook/.babelrc{
  "presets": [
    "@babel/preset-env",
    "babel-preset-vue"
  ]
}
// /.storybook/.webpack.config.jsconst path = require('path')module.exports = {
  resolve: {
    alias: {
      '@': path.dirname(path.resolve(__dirname))
    }
  }
}

Module parse failed: handling SCSS

Storybook now throws:

// /.storybook/.webpack.config.jsconst path = require('path')module.exports = {
  module: {
    rules: [
      {
        test: /\.s?css$/,
        loaders: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
        include: path.resolve(__dirname, '../')
      }
    ]
  },
  resolve: {
    alias: {
      '@': path.dirname(path.resolve(__dirname))
    }
  }
}
We still need to configure the Store!

Using Vuex with Storybook

If you’ve followed Storybook’s Vue guidelines prior to this walkthrough, you should already be importing and using Vuex in config.js.

// /.storybook/config.jsimport Vue from 'vue'
import Vuex from 'vuex'import { configure } from '@storybook/vue'
import '@/assets/css/tailwind.css'Vue.use(Vuex)function loadStories() {
  const req = require.context('../components', true, /\.stories\.js$/)
  req.keys().forEach(filename => req(filename))
}configure(loadStories, module)
// /.storybook/store.jsimport Vue from 'vue'
import Vuex from 'vuex'import axios from 'axios'// You can do the same for getters, mutations and states
import actions from '@/store/actions'let store = new Vuex.Store({
  actions: actions
})/**
  Bind Axios to Store as we don't have access to Nuxt's $axios instance here. See caveat below.
**/
store.$axios = axiosexport default store
// /components/list/List.stories.jsimport Vue from 'vue'
import { storiesOf } from '@storybook/vue'
import List from '@/components/list/List'
import store from '@/.storybook/store'storiesOf('Lists', module)
  .add('Users', () => ({
    components: { List },
    store: store,
    template: '<List />'
  }))
  .add('Comments', () => ({
    components: { List },
    store: store,
    template: `<List :source="'comments'" />`
  }))
Both List stories using Vuex actions to load data from an API. 😌

Handling <nuxt-link>

Finally we can see something! But our links are missing..

// /.storybook/addons.jsimport '@storybook/addon-actions'
import '@storybook/addon-actions/register'
// /.storybook/config.jsimport Vue from 'vue'
import Vuex from 'vuex'import { configure } from '@storybook/vue'
import { action } from '@storybook/addon-actions'import '@/assets/css/tailwind.css'Vue.use(Vuex)Vue.component('nuxt-link', {
  props:   ['to'],
  methods: {
    log() {
      action('link target')(this.to)
    },
  },
  template: '<a href="#" @click.prevent="log()"><slot>NuxtLink</slot></a>',
})function loadStories() {
  const req = require.context('../components', true, /\.stories\.js$/)
  req.keys().forEach(filename => req(filename))
}configure(loadStories, module)
Links are now working.

Storybook working with Nuxt!

It took a while but we’ve managed to have Storybook working nicely with Vue.js components within a Nuxt.js project.

Bonus: deploy Storybook to Netlify

When running Storybook, you get an IP you can share to others in your local network and that’s cool if you’re on the same WiFi. But what if you want to share it to your clients so they can give you feedback on last week’s iteration?

"build-storybook": "build-storybook -c .storybook" 
The usual Netlify build settings.
// /.storybook/webpack.config.jsconst webpack = require('webpack')module.exports = async ({ config, mode }) => {  config.plugins.push(new webpack.DefinePlugin({
    'process.env': {
      YOUR_VARIABLE: JSON.stringify(process.env.YOUR_VARIABLE)
    }
  }))  return config}

Final Repo & Resources

Feel free to grab the code on Github (https://github.com/mstrlaw/nuxt-storybook) and check out this reading material and other repos that were useful to build this guide:



Vue.js Developers

Helping web professionals up their skill and knowledge of Vue.js

Lawrence Braun

Written by

I build web things. Infoholic. I run https://thoro.news when I have time.

Vue.js Developers

Helping web professionals up their skill and knowledge of Vue.js