How to Build a Storefront with Nuxt.js and BigCommerce

Contents
    Try Storyblok

    Storyblok is the first headless CMS that works for developers & marketers alike.

    In this tutorial, we will use Storyblok to build a storefront with BigCommerce and Nuxt.js. You can find the source for this tutorial on Github, the take a look at the demo: storyblok-storefront-nuxt.vercel.app

    If you are in a hurry, you can download the whole source code of the project at Github github.com/storyblok/big-commerce-nuxt-starter or jump into one of the following chapters.

    1. Creating a Storyblok Space

    2. Adding the eCommerce Integration

    3. Storefront in Nuxt.js

    4. Understanding the Components

    5. Custom Product Detail Page

    6. Deployment

    Storyblok BigCommerce Starter

    Storyblok Space

    Sign up for a free Storyblok account & choose Create a new space {1}. Then name your Storefront {2}, and click the create space {3} button.

    Create a new Space

    Once you create a new space, follow our eCommerce Storefront documentation for setting up your storefront in Storyblok. After you finish this, you should have some basic components set up like in the image below {1}.

    Storefront in Storyblok

    eCommerce Integration

    Before you can move to Nuxt.js and build your Storefront, you must set up your Integration plugins. Follow the BigCommerce guide on how to retrieve your endpoint credentials and then the integration plugin guide on how to set it up. Once your integration field-type plugin is working, you should be able to select some products or categories on your Storyblok components like in the image below {1}.

    Select Products

    Storefront in Nuxt.js

    We have prepared a Nuxt.js starter template to show you how to connect the eCommerce plugin with the eCommerce API to build your storefront. For this example, you will use the BigCommerce API as your eCommerce provider. You can take a look at the Demo before getting started.

    Start by cloning the starter repository.

    $ git clone https://github.com/storyblok/big-commerce-nuxt-starter.git
    $ cd big-commerce-nuxt-starter
    $ npm i

    Connecting your Storyblok Space with the Starter

    To connect to the storefront space created in the storefront tutorial, get your tokens from the Storyblok Space Settings {1} under API-Keys {2}.

    Fill the Location (default environment) {4} as well as the Preview URLs {3} field with your localhost URL http://localhost:3000/. Don't forget the trailing slash /. If you have a staging or production server, the default environment can also be set to a different staging URL {4}.

    Storyblok Settings

    Copy the preview token {2} into the nuxt.config.js file. Exchange accessToken with the preview token of your space.

    modules: [
        [
          'storyblok-nuxt',
          { accessToken: 'secret-preview-token', cacheProvider: 'memory' },
        ],
      ],

    You will also need your eCommerce endpoint and tokens. Open the nuxt.config.js file and add the storeUrl and storeToken into the env object at the beginning of the file.

     env: {
        storeUrl: 'https://my-demo-store.mybigcommerce.com',
        storeToken: 'secret-bigCommerce-bearer-token',
      },

    If you don't want to add them directly, you can also make use of the .env-template and rename it to .env. Then enter the following variables:

    BIGCOMMERCE_URL=https://your-demo-store.mybigcommerce.com
    BIGCOMMERCE_TOKEN=eyJ....JBw
    STORYBLOK_TOKEN=rN...tt

    These .env variables are then loaded via the process.env variable in the nuxt.config.js file:

    nuxt.config.js

      env: {
        storeUrl:
          process.env.BIGCOMMERCE_URL || 'https://demo-store.mybigcommerce.com',
        storeToken: process.env.BIGCOMMERCE_TOKEN || '',
      },
    
    ...
    
    modules: [
        [
          'storyblok-nuxt',
          {
            accessToken: process.env.STORYBLOK_TOKEN || 'preview-token',
            cacheProvider: 'memory',
          },
        ],
      ],

    If the API is CORS restricted, you might need to create a separate token for your localhost/production server. In the case of BigCommerce & Nuxt.js, you need to create a new token for http://localhost:3000 for the local development and then create a different token with the real URL once you deploy your store.

    After you add your tokens, you can start the development server:

    npm run dev

    Changing the Real Path Field

    When you open your Home Page in Storyblok, you will see a page not found error. Since Storyblok automatically creates a /home path from the Home story, you want to redirect that to your base URL in the Nuxt application /.

    To fix this routing issue, change the real path of your homepage. When you open the home entry, you should already see the running localhost on port 3000 {1}. Now navigate to the config tab {2} and change the real path field to / {3}.

    Reload the page and you should see your Nuxt.js application in the preview space. Be sure you hit the Save button before reloading.

    Real Path Replace

    If all goes well, you should see a similar preview to the one below, which is already loading the right components. If you named your components differently in Storyblok, some of the components won't be loaded. We will get to that in the next section. Make sure you have your Dev Preview set up to see the localhost on port 3000 {3}. Components should be editable, so when you click on them, they should open in the editor on the right {2}.

    Storyblok editing capabilities

    Understanding the Components

    A few components are already defined in the Starter, but take a look at what is happening with the components in more detail. Since Storyblok is built to prioritize a component-based approach, you will find the same components in Storyblok and the Nuxt starter template. Once Nuxt loads your Story from the Storyblok API it will automatically inject the right components based on their order in the CMS.

    Index.vue

    Check out the Index Page. In the <template> part, you iterate over all the components created in the storefront tutorial: the teaser, the category bar, the featured product. Storyblok then injects the correct Nuxt component based on the name of the component in Storyblok. If the Storyblok Component can't be mapped to a component in the Nuxt Starter, it will inject a Placeholder component.

    In the asyncData() function, which is specific to Nuxt.js, you load your Home story from the Storyblok API. By making use of this function, you're fetching and rendering the data on the server-side.

    In the mounted()  function of your index.vue, we're loading the Storybridge to enable live editing in our editor. 

    index.vue

    # index.vue
    <template>
      <div v-if="story.content">
        <template v-for="component in story.content.body">
          <component
            :is="`blok-${dashify(component.component)}`"
            v-if="
              availableComponents.includes(`blok-${dashify(component.component)}`)
            "
            :key="component._uid"
            :blok="component"
          ></component>
          <Placeholder v-else :key="component._uid" :blok="component" />
        </template>
      </div>
    </template>
    
    <script>
    import dashify from 'dashify'
    import { availableComponents } from '../plugins/components'
    import Placeholder from '../components/Placeholder'
    
    export default {
      components: {
        Placeholder,
      },
      asyncData(context) {
        return context.app.$storyapi
          .get('cdn/stories/home', {
            version: 'draft',
          })
          .then((res) => {
            return res.data
          })
          .catch((res) => {
            if (!res.response) {
              console.error(res)
              context.error({
                statusCode: 404,
                message: 'Failed to receive content from api',
              })
            } else {
              console.error(res.response.data)
              context.error({
                statusCode: res.response.status,
                message: res.response.data,
              })
            }
          })
      },
      data() {
        return {
          story: {},
          error: null,
          availableComponents,
        }
      },
      mounted() {
        this.$storybridge.on(['input', 'published', 'change'], (event) => {
          if (event.action === 'input') {
            if (event.story.id === this.story.id) {
              this.story.content = event.story.content
            }
          } else {
            window.location.reload()
          }
        })
      },
      methods: {
        dashify,
      },
    }
    </script>

    All the components are loaded globally in the plugins/components.js file.

    plugins/components.js

    import Vue from 'vue'
    import Page from '~/components/Page.vue'
    import Teaser from '~/components/Teaser.vue'
    import Products from '~/components/Products.vue'
    import ProductGrid from '~/components/ProductGrid.vue'
    import Feature from '~/components/Feature.vue'
    import CategoryProducts from '~/components/CategoryProducts.vue'
    
    Vue.component('blok-page', Page)
    Vue.component('blok-hero', Teaser)
    Vue.component('blok-product-feature', Feature)
    Vue.component('blok-product-slider', Products)
    Vue.component('blok-product-grid', ProductGrid)
    Vue.component('blok-category-products', CategoryProducts)
    
    export const availableComponents = [
      'blok-page',
      'blok-hero',
      'blok-product-feature',
      'blok-product-slider',
      'blok-product-grid',
      'blok-category-products',
    ]
    

    Notice that the different Components, like the Hero component, are injected with a matching string blok-hero. Let's take a look at a specific component to understand the connection to Storyblok:

    Product Grid

    In the documentation for setting up a Storefront in Storyblok, a component called Product Grid was created. Now we want to display some categories {1} to the left and some products to the right {2}, like in the image below.

    Draft JSON

    When you click the arrow icon on the top right {1} and then Draft JSON {2}, you should see the associated data from your home story that is returned from the plugin.

    Draft JSON select

    If you set up your components and plugin field-types correctly you should see the following data:

    Draft JSON

    The API always returns a story object {1} with a content object {2}. In the Home entry there is a body with a collection of bloks {3} which returns an array of components. If you look at the second component in this array, you can see that it's the Featured Products component with the eCommerce integration plugin set up {6}. The plugin has a property items {7} with an array of products. Each product has an id {8} to query the product from the eCommerce API. It also returns whether this item is a product or a category {9}.

    If we take a look at the first component Hero, we see it has a component property with the technical name of the component {4}. This name will be used later to match the right component in your Nuxt application in plugins/components.js.

    import Vue from 'vue'
    import Teaser from '~/components/Teaser.vue'
    
    Vue.component('blok-hero', Teaser)

    It also has an _editable property {5} which is used to enable the live editor and passed to our components. This property is only available in the draft version of a story.

    Now let's take a look at the component implementation in the components/ProductGrid.vue file.

    components/ProductGrid.vue

    <template>
      <div
        v-editable="blok"
        class="flex flex-row flex-wrap mx-auto container mt-16 lg:px-0 px-8"
      >
        <aside class="w-1/5">
          <h2 class="text-xl font-bold mb-4">All Categories</h2>
          <ul>
            <li
              v-for="cat in fullCategories"
              :key="cat.entityId"
              class="hover:opacity-50 transition-opacity duration-200"
            >
              <nuxt-link :to="`/categories${cat.path}`">
                {{ cat.name }}
              </nuxt-link>
            </li>
          </ul>
        </aside>
        <div class="flex flex-row flex-wrap mx-auto w-4/5">
          <nuxt-link
            v-for="product in fullProducts"
            :key="product.entityId"
            class="flex w-full md:w-1/2 lg:w-1/3"
            :to="`/product${product.path}`"
          >
            <div
              class="flex-shrink-0 m-6 relative overflow-hidden rounded-lg max-w-xs hover:opacity-50"
            >
              <div class="flex flex-col justify-center px-6 pb-6">
                <div class="prod-img">
                  <img
                    v-if="product.defaultImage"
                    :src="product.defaultImage.img320px"
                    class="w-full object-cover object-center"
                  />
                </div>
                <div class="prod-title">
                  <p class="text-xs mt-4 mb-2 uppercase text-gray-900">
                    {{ product.name }}
                  </p>
                </div>
                <div class="prod-info grid gap-10">
                  <div
                    class="flex flex-col md:flex-row justify-between items-center text-gray-900"
                  >
                    <p v-if="product.prices" class="font-bold text-xl">
                      {{
                        `${product.prices.price.value} ${product.prices.price.currencyCode}`
                      }}
                    </p>
                  </div>
                </div>
              </div>
            </div>
          </nuxt-link>
        </div>
      </div>
    </template>
    
    <script>
    import {
      categoriesByIds,
      getProductsById,
    } from '../plugins/graphql-bigcommerce'
    
    export default {
      props: {
        blok: Object,
        products: Object,
        categories: Object,
        error: Object,
      },
      data() {
        return {
          fullCategories: [],
          fullProducts: [],
        }
      },
      async mounted() {
        const categoryIds = this.blok.categories.items.map((i) => i.id)
        const productIds = this.blok.products.items.map((i) => i.id)
        const productResponse = await getProductsById(productIds)
        const categoryResponse = await categoriesByIds(categoryIds)
        this.fullProducts = productResponse.site.products.edges
          .map((e) => e.node)
          .filter((e) => productIds.includes(e.entityId))
          .sort((a, b) => {
            return productIds.indexOf(a.entityId) - productIds.indexOf(b.entityId)
          })
        this.fullCategories = categoryResponse.sort((a, b) => {
          return categoryIds.indexOf(a.entityId) - categoryIds.indexOf(b.entityId)
        })
      },
    }
    </script>

    What the component essentially does is loop through the items returned by the eCommerce field-type and load the categories and products on the client-side. This is not ideal for performance, but makes sure all your prices and products are up to date without the need to rebuild the entire site. In the mounted function we also sort the products to the order of the products and categories in Storyblok, so you can reorder them from within Storblok in any way you want. If you wanted to load the data on the server side, you would need to do that beforehand in the asyncData function of the index.vue file. Since the plugin only saves the id, you query the complete categories and products from the eCommerce API. So the path ends up like category/kitchen. The pages the categories and products link to are auto-generated in pages/categories/_path.vue and pages/product/slug.vue. Looking at this template for the category page, you can see that it gets the correct category from the BigCommerce API and loads it with all its products with the asyncData function.

    pages/categories/_path.vue

    <template>
      <div v-if="category.path">
        <Category :category="category" :error="error" />
      </div>
    </template>
    
    <script>
    import { getProductsByPath } from '../../plugins/graphql-bigcommerce'
    import Category from '~/components/Category.vue'
    
    export default {
      components: {
        Category,
      },
      data() {
        return {
          category: {},
          error: null,
        }
      },
      async asyncData(context) {
        const categoryPath = context.params.path
        const res = await getProductsByPath(categoryPath)
    
        return {
          category: res.site.route.node,
        }
      },
    }
    </script>

    In the end, you will have an auto-generated page for each category that looks similar to the image below. You can also check out the page in the demo.

    Storyblok editing capabilities

    Custom Product Detail Page

    You might want to create custom pages for specific products that have some additional content as seen in the Demo, where a quote is added to a product.

    To get there, set up a folder called product to allow for custom Product Detail Pages and create one entry with a specific slug, as described in the storefront setup tutorial. When you open this entry now and the product with this slug exists also in your eCommerce API, you should see a product preview page. The visible content is loaded via the slug parameter in the URL {1}. Now you can add some additional content in Storyblok like a headline {2} and a description {3}. If additional content is available in Storyblok, it will replace the headline and description loaded from the eCommerce API {2, 3}. All the other content like the price {4} will still be loaded from the eCommerce API.

    Product Detail Page

    If you take a look at your template file for products in pages/product/_slug.vue, you can see that there are two requests. One is to the BigCommerce API to get the details for the product, like the Price and Product Description, and another one is to Storyblok to get the details for this product, if they exist in Storyblok. Otherwise, it will only load the product from the eCommerce API.

    pages/product/_slug.vue

    <template>
      <section
        v-editable="story"
        v-if="product"
        class="text-gray-700 body-font overflow-hidden bg-white"
      >
        <div class="container px-5 py-24 mx-auto">
          <div class="lg:w-4/5 mx-auto flex flex-wrap">
            <img
              v-if="product.defaultImage"
              :alt="product.defaultImage.altText"
              class="lg:w-1/2 w-full object-cover object-center rounded border border-gray-200"
              :src="product.defaultImage.img1280px"
            />
            <div class="lg:w-1/2 w-full lg:pl-10 lg:py-6 mt-6 lg:mt-0">
              <p class="text-sm title-font text-gray-500 tracking-widest">
                <span
                  v-for="category in product.categories.edges"
                  :key="category.node.name"
                  >{{ category.node.name }} -</span
                >
              </p>
              <h1 class="text-gray-900 text-3xl title-font font-medium mb-1">
                {{ story.content ? story.content.name : product.name }}
              </h1>
              <div v-if="story.content" class="leading-relaxed" v-html="richtext" />
              <p v-else class="leading-relaxed" v-html="product.description"></p>
              <span class="block mt-4 pb-5 border-b-2 border-gray-200 mb-5"></span>
              <div class="flex">
                <span class="title-font font-medium text-2xl text-gray-900">
                  {{
                    `${product.prices.price.value} ${product.prices.price.currencyCode}`
                  }}
                </span>
                <a
                  :href="product.addToCartUrl"
                  class="flex ml-auto text-white bg-primary border-0 py-2 px-6 focus:outline-none hover:bg-gray-800 rounded"
                  >Go to shop</a
                >
                <a
                  :href="product.addToWishlistUrl"
                  class="rounded-full w-10 h-10 bg-gray-200 p-0 border-0 inline-flex items-center justify-center text-gray-500 ml-4"
                >
                  <svg
                    fill="currentColor"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    stroke-width="2"
                    class="w-5 h-5"
                    viewBox="0 0 24 24"
                  >
                    <path
                      d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"
                    />
                  </svg>
                </a>
              </div>
            </div>
          </div>
        </div>
      </section>
    </template>
    
    <script>
    import {
      getProductBySlug,
      getProductById,
    } from '../../plugins/graphql-bigcommerce'
    
    export default {
      async asyncData(context) {
        let story = {}
        let commerceResponse = {}
        let p2 = {}
    
        try {
          const { data } = await context.app.$storyapi.get(
            `cdn/stories/product/${context.params.slug}`,
            {
              version: 'draft',
            }
          )
          story = data.story
        } catch (e) {
          console.warn(e)
        }
    
        try {
          commerceResponse = await getProductBySlug(`/${context.params.slug}`)
          p2 = await getProductById(commerceResponse.site.route.node.entityId)
        } catch (e) {
          console.warn(e)
        }
    
        return {
          story,
          product: p2.site.product,
        }
      },
      data() {
        return {
          product: null,
          story: {},
          error: null,
        }
      },
      computed: {
        richtext() {
          return this.$storyapi.richTextResolver.render(
            this.story.content.description
          )
        },
      },
      mounted() {
        this.$storybridge.on(['input', 'published', 'change'], (event) => {
          if (event.action === 'input') {
            if (event.story.id === this.story.id) {
              this.story.content = event.story.content
            }
          } else if (!event.slugChanged) {
            window.location.reload()
          }
        })
      },
    }
    </script>
    

    Finally, you can show the additional Storyblok information in the template if your request finds a product in Storyblok. With v-editable you can make the editable part in Storyblok clickable for the editor.

    <div v-if="story.content" class="leading-relaxed" v-html="richtext" />
    <p v-else class="leading-relaxed" v-html="product.description"></p>

    Now there is some custom content on the Product Detail Page.

    By connecting two headless systems you can allow a very flexible structure and reorder any content with Storyblok’s powerful visual editor.

    Reorder Components

    Deployment

    You can deploy your Storefront by running the Nuxt generate command, which creates a deployable directory dist:

    $ npm run generate

    We can deploy Nuxt applications on any service like Vercel or Netlify. Read more about the deployments on the Nuxt documentation. If we want to deploy for example with Vercel, we would need an Vercel account and their CLI tool. Then we can deploy the dist directory with the Vercel CLI:

    $ npm i -g vercel
    $ vercel

    Conclusion

    Connecting two headless based systems like BigCommerce and Storyblok can greatly enhance your workflow and the user experience. With Storyblok's Visual Editor you get a great editing experience, while still keeping all your store and product information in your eCommerce system. The other big advantage is in terms of performance. With a JAMstack based storefront, you can decrease page loading times and hopefully increase purchases. If you want to learn more about why Storyblok is a great choice as a CMS for your eCommerce experiences, we recommend reading our article CMS for eCommerce.