How to Build a Storefront with Next.js and BigCommerce

Contents

In this tutorial, you will build a Storefront connecting the Storyblok, BigCommerce and Next.js Commerce systems. Take a look at the end result demo: nextjs-bigcommerce-starter.vercel.app

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

  1. Set up the eCommerce Integration

  2. Storefront with Next.js Commerce

  3. Storefront Setup in Storyblok

  4. Connecting Storyblok and the Next.js Starter

  5. Adding Next.js Preview Mode

  6. Custom Product Detail Page

  7. Deployment

Next.js BigCommerce Demo Screens

Requirements

Warning:

The project in this article was developed using the following versions of these technologies:

  • Next.js v10.0.1
  • Nodejs v12.3.1
  • Npm v6.9.0

Keep in mind that these versions may be slightly behind the latest ones.

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 it, you should have some basic components set up like in the image below {1}.

Storefront in Storyblok

eCommerce Integration

Before you can move to Next.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 setup the field-type plugin. 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 with Next.js Commerce

We prepared a complete Next.js starter template with the end results on Github to show you how to connect the eCommerce plugin with the eCommerce API. For this example, we will use the BigCommerce API as our eCommerce provider. You can take a look at the finished Demo before you get started to get a better understanding of the following tutorial.

Start by cloning the Next.js Commerce starter.

$ git clone git clone https://github.com/vercel/commerce.git nextjs-bigcommerce-starter
$ cd nextjs-bigcommerce-starter
$ npm install

Connecting Your Storyblok Space with the Starter

To connect the storefront space that was 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} fields 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

After installing all of your dependencies, it's time to connect the Next.js commerce starter to Storyblok.

Duplicate the .env.template file to a .env.local file and add your BigCommerce credentials as well as your Storyblok preview token:

.env.local file

BIGCOMMERCE_STOREFRONT_API_URL=https://my-demo-store.mybigcommerce.com/graphql
BIGCOMMERCE_STOREFRONT_API_TOKEN=eyJ0...Q
BIGCOMMERCE_STORE_API_URL=https://api.bigcommerce.com/stores/a21234xyz
BIGCOMMERCE_STORE_API_TOKEN=927...cn
BIGCOMMERCE_STORE_API_CLIENT_ID=ab12..e2

STORYBLOK_TOKEN=zT...tt

After adding your tokens start the development server:

npm run dev

You should already see the Next.js landing page with all our BigCommerce products on our localhost server.

NextJs Starter

Changing the Real Path Field

If you open the 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 Next application /.

To fix this routing issue, change the real path of your homepage. If you open the home entry, you should already see your 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 Next.js application in the preview space. Be sure you hit the Save button before reloading.

Real Path Replace

Now if all goes well, you should see a similar preview to the one below, which shows your localhost within Storyblok.

Nextjs Commerce in Storyblok

Create a Storefront in Storyblok

The next step is to be able to edit this homepage from within Storyblok. First, you'll want to create a few components in Storyblok. If you look at the Next.js commerce home page, you'll see the following components: Three featured products, a slider of products, a text feature, and a product grid. If you followed our Storefront Setup guide you should already have featured products and product slider set. You will need to add two more components to that: the text feature and the product grid. Do that now.

Adding a Text Feature Component

Go to your Content Space and open the Home Story. Click on Add Block and add a new block called Text Feature.

Text Feature

The Text Feature {1} should have the following schema: a headline {2} for text type, a description {3} of Richtext type and a Link {4} for the link type .

Schema of the Text Feature:

-- headline (type: Text)
-- description (type: Richtext)
-- Link (type: Link)
Text Feature Schema

Adding a Product Grid Component

Click on Add Block again and add a new block called Product Grid with the following schema. If you don't know yet how to set up the eCommerce integration plugins, take a look at our integration setup plugin guide.

Schema of the Product Grid:

-- categories (type: Plugin) {1}
-- products (type: Plugin) {2}
Product Grid

Adding the Content

Now that all of the components are set up it's time to mirror the components that are in the Next.js starter in your Storyblok Home Story. If you go through the different components in the Next.js commerce home page, you'll find the featured products with 3 products listed: a product slider, a hero text feature, and the product grid. You should have the same components in Storyblok as you do in Next.js, similar to the image below {1}.

Content added

And the plugin should have some categories and/or products selected similar to what you see on the product grid component in the image below {1}.

Plugin Setup

Take a look at your draft JSON from the home story by clicking the arrow icon on the top right {1} and then Draft JSON {2}. You should see the associated data that is returned from the eCommerce integration plugin.

Draft JSON

If you set up your components and plugin field-types correctly you should see some data. Storyblok always returns a story object {1} with some content {2}. In this case, the content has a body {3} with an array of blocks. These blocks are your components and your first component has an eCommerce integration plugin set up {4}. The plugin has an array of product items {5}, where each item has id, sku, name, type (can be product or category), image, and a short description. You will mostly need the id to query the complete information like prices or variations from BigCommerce.

Draft JSON

Add Storyblok to Next.js Starter

Finally, it's time to connect Storyblok to the Next.js Starter. First, install the storyblok-react and storyblok-js-client packages.

Install the Storyblok Client and React Module

The Storyblok client allows you to request content from Storyblok's API, and the storyblok-react module gives you a wrapper component that creates editable blocks in the live visual editor/composer of Storyblok.

$ npm install storyblok-js-client storyblok-react --save

Create a Storyblok Service

First, create a StoryblokService class that initializes the client and provides you with a few helpers. Create the lib/storyblok.ts file in the folder lib. Copy and paste the code below. This loads your Storyblok client and sets it up with the credentials. It also includes the code to implement the live preview. Your Storyblok preview token is automatically read from the .env.local file.

lib/storyblok.ts

import StoryblokClient, { StoryblokComponent, StoryData } from 'storyblok-js-client'
import { ParsedUrlQuery } from 'querystring';
import { merge, debounce } from 'lodash'

interface Editor {
  content: StoryblokComponent<string> & { body: StoryblokComponent<string>[] }
  setContent: (content: any) => Promise<void> | any
}

class StoryblokService {
  devMode: boolean
  client: StoryblokClient
  query: ParsedUrlQuery | undefined
  token: string | undefined
  constructor() {
    this.devMode = false //  True loads draft
    this.token = process.env.STORYBLOK_TOKEN
    this.client = new StoryblokClient({
      accessToken: this.token,
      cache: {
        clear: 'auto',
        type: 'memory'
      }
    })

    this.query = { }
  }

  getCacheVersion() {
    return this.client.cacheVersion
  }

  get(slug: string, params: { version?: any; cv?: any }) {
    params = params || {}

    if (this.getQuery('_storyblok') || this.devMode || (typeof window !== 'undefined' && window.storyblok)) {
      params.version = 'draft'
    }

    if (typeof window !== 'undefined' && typeof window.StoryblokCacheVersion !== 'undefined') {
      params.cv = window.StoryblokCacheVersion
    }

    return this.client.get(slug, params)
  }

  initEditor({ content, setContent }: Editor) {
    if (window.storyblok) {
      window.storyblok.init()
      window.storyblok.on(['change', 'published'], () => location.reload(true))

      // this will alter the state and replaces the current story with a current raw story object (no resolved relations or links)
      const inputFunction = (event: any) => {
        if (event && event.story.content._uid === content._uid) { 
            const newContent: any =  window.storyblok.addComments(event.story.content, event.story.id)

            if(newContent.body) {
              // we need to track changes if bloks are rearranged in storyblok
              const mergedBody: any[] = newContent.body?.map((item: StoryblokComponent<string>) => {
                // and keep the old fetched data, that is not stored within storyblok
                let oldItem = content.body.find((itemWithData: StoryblokComponent<string>) => itemWithData._uid === item._uid)
                // we keep the old data and overwrite it with the new
                return merge(oldItem, item)
              }).filter(Boolean)

              newContent.body = mergedBody
            }
            setContent(newContent)
        }
      }

      // we will debounce the funcction since we're doing some data processing inside
      window.storyblok.on('input', debounce(inputFunction, 300))
    }
  }

  setQuery(query: ParsedUrlQuery | undefined) {
    this.query = query
  }

  getQuery(param: string) {
    if(this.query) {
      return this.query[param]
    }
  }
}

const storyblokInstance = new StoryblokService()

export default storyblokInstance

In the development phase, it is useful to set the variable this.devMode to true so you can retrieve unpublished content from the Storyblok API without needing to use Storyblok’s preview mode.

The Service also has an adapted initEditor function that makes use of React Hooks to return the content that was changed during live editing. It's necessary to hook into this function because you're loading additional data from BigCommerce and want to keep that data independent from what has changed in Storyblok. So after a live edit happens in Storyblok you're merging this already existent data with Storyblok's new data.

Another possibility is reloading the data from BigCommerce, but that would result in a lot more requests on every live edit and depends on your use case.

Adding the Storyblok Bridge and Passing the Cache Version

For the best performance, we recommend that you pass the cache version that gets generated on the server-side to the client-side. You can do this by setting the window variable StoryblokCacheVersion in a script tag which gets handled later by the code in storyblok.ts.

Open the file components/core/Head.tsx and insert the following code. This way, all of the API requests to Storyblok will be cached in the CDN properly. You can read more about the caching technique in the content delivery documentation. Furthermore, you're adding the Storyblok Bridge for the Visual Editor.

components/core/Head.tsx

import { FC } from 'react'
import NextHead from 'next/head'
import { DefaultSeo } from 'next-seo'
import config from '@config/seo.json'
import StoryblokService from '@lib/storyblok'

const Head: FC = () => {
  return (
    <>
      <DefaultSeo {...config} />
      <NextHead>
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="manifest" href="/site.webmanifest" key="site-manifest" />
        <script defer src={`//app.storyblok.com/f/storyblok-latest.js?t=${StoryblokService.token}`} type="text/javascript" />
        <script defer type="text/javascript">var StoryblokCacheVersion = '${StoryblokService.getCacheVersion()}';</script>
      </NextHead>
    </>
  )
}

export default Head

Add a Dynamic Component Loader

Next, you want to dynamically load the right component dependent on which components you have in Storyblok. Add a new folder components/core/DynamicComponent with two new files: a DynamicComponent.tsx and a index.tsx file.

components/core/DynamicComponent/DynamicComponent.tsx


import React, { FC } from 'react'
import { Grid, Marquee, Hero } from '@components/ui'
import HomeAllProductsGrid from '@components/core/HomeAllProductsGrid'
import { SbEditableContent } from "storyblok-react";
import dashify from 'dashify'

interface IComponents {
  [key: string]: React.ElementType,
}

interface Props {
  blok?: SbEditableContent
}

const Components: IComponents = {
  'product-feature': Grid,
  'product-slider': Marquee,
  'text-feature': Hero,
  'product-grid': HomeAllProductsGrid,
} 

const DynamicComponent: FC<Props> = ({ blok }) => {
    if (blok) {
      const componentName = dashify(blok.component)
      if(typeof Components[componentName] !== 'undefined') {
        const FoundComponent = Components[componentName]
        return (<FoundComponent blok={blok} />)
      } else {
        return (<div>The component {componentName} has not been created yet.</div>)
      }
    }
    return null
  }
  
  export default DynamicComponent

This component resolves the components you defined in Storyblok to components defined within the Next.js commerce starter. Next, add a index.tsx file:

components/core/DynamicComponent/index.tsx

export { default } from './DynamicComponent'

Add a Dynamic SbEditable Component

Finally, add a dynamic component for SbEditable from the storyblok-react package. This component will be a wrapper in case content from Storyblok is present and will enable the live editing on components where that is the case. Otherwise, it will load the component without the Storyblok content. Create a components/core/DynamicSbEditable/DynamicSbEditable.tsx file:

components/core/DynamicSbEditable/DynamicSbEditable.tsx

import { FC, ReactNode, Component, Fragment } from 'react'
import SbEditable, { SbEditableContent } from 'storyblok-react'

interface Props {
  content?: SbEditableContent
}

const DynamicSbEditable: FC<Props> = ({ content, children }) => {
  if (typeof content !== 'undefined') {
    return <SbEditable content={content} key={content._uid}>{children}</SbEditable>
  }
  return <Fragment>{children}</Fragment>
}

export default DynamicSbEditable

components/core/DynamicSbEditable/index.tsx

export { default } from './DynamicSbEditable'

To be able to load both components you also need to add them to the core component file. Add the DynamicComponent and DynamicSbEditable to components/core/index.ts:

...
export { default as DynamicComponent } from './DynamicComponent'
export { default as DynamicSbEditable } from './DynamicSbEditable'
Hint:

You can check these changes in the commit Add Storyblok Client and Loaders

Adapting the Components to Storyblok

Since you want to add the Storyblok content and have access to a live preview, you will need to adapt the four components you have in Storyblok as well as the Next.js template.

Grid.tsx

Start with the Grid component. Add a blok prop and wrap it in the DynamicSbEditable component. The blok property will have all of your products, which are passed to the component.

components/ui/Grid/Grid.tsx

import cn from 'classnames'
import { FC, ReactNode, Component } from 'react'
import { SbEditableContent } from "storyblok-react";
import s from './Grid.module.css'
import { ProductCard } from '@components/product'
import { DynamicSbEditable } from '@components/core'

interface Props {
  className?: string
  blok?: SbEditableContent
  children?: ReactNode[] | Component[] | any[]
  products?: ReactNode[] | Component[] | any[]
  layout?: 'A' | 'B' | 'C' | 'D' | 'normal'
  variant?: 'default' | 'filled'
}

const Grid: FC<Props> = ({
  className,
  layout = 'A',
  children,
  products = [],
  blok, 
  variant = 'default',
}) => {
  const activeLayout =  blok?.layout ? blok.layout : layout
  const rootClassName = cn(
    s.root,
    {
      [s.layoutA]: activeLayout === 'A',
      [s.layoutB]: activeLayout === 'B',
      [s.layoutC]: activeLayout === 'C',
      [s.layoutD]: activeLayout === 'D',
      [s.layoutNormal]: activeLayout === 'normal',
      [s.default]: variant === 'default',
      [s.filled]: variant === 'filled',
    },
    className
  )
  const activeProducts = blok?.product ? 
  blok.product?.items?.map((sbProduct: any) => 
    blok.product?.fetchedItems?.find(({node}:any) => 
    node.entityId === sbProduct.id)).filter(Boolean) 
  : products;


  return (
    <div className={rootClassName}>
        <DynamicSbEditable content={blok}>
          { children }
          {activeProducts ? activeProducts?.map(({ node }: any) => (
            <ProductCard
              key={node.path}
              product={node}
              variant="simple"
              // The first & last image are the largest one in the grid
              imgWidth={1600}
              imgHeight={1600}
              priority
            />
          )) : null }
        </DynamicSbEditable>
      </div>
  )
}

export default Grid

If you have a layout {3} in Storyblok you enter the Grid component. You can also enter the order and products selected in your eCommerce plugin so you can reorder the products in the live preview {2}.

Grid Component

Marquee.tsx

Similarly, add Storyblok content to the Slider component.

components/ui/Marquee/Marquee.tsx

import cn from 'classnames'
import s from './Marquee.module.css'
import { FC, ReactNode, Component } from 'react'
import { SbEditableContent } from "storyblok-react";
import Ticker from 'react-ticker'
import { ProductCard } from '@components/product'
import { DynamicSbEditable } from '@components/core'

interface Props {
  className?: string
  blok?: SbEditableContent
  children?: ReactNode[] | Component[] | any[]
  products?: ReactNode[] | Component[] | any[]
  variant?: 'primary' | 'secondary'
}

const M: FC<Props> = ({ className = '', blok, children, products = [], variant = 'primary' }) => {
  const activeVariant =  blok?.variant ? blok.variant : variant
  const rootClassName = cn(
    s.root,
    {
      [s.primary]: activeVariant === 'primary',
      [s.secondary]: activeVariant === 'secondary',
    },
    className
  )

  const activeProducts = blok?.products ? 
  blok.products.items.map((sbProduct: any) => 
    blok.products?.fetchedItems?.find(({node}:any) => 
    node.entityId === sbProduct.id)).filter(Boolean) 
  : products;

  return (
    <DynamicSbEditable content={blok}>
    <div className={rootClassName}>
      <Ticker offset={80}>
        {({ index }) => (<div className={s.container}>
          { children }
          { activeProducts &&
            activeProducts.map(({ node }: any) => (
              <ProductCard
                key={node.path}
                product={node}
                variant="slim"
                imgWidth={320}
                imgHeight={320}
              />
            ))
          }
        </div>)}
      </Ticker>
    </div>
    </DynamicSbEditable>
  )
}

export default M

Similarly to the Grid component, you pass on the variant {3}, and the products {2}, and wrap it with your DynamicSbEditable to make it editable.

Slider Component

Hero.tsx

In the Hero component, render your Richtext field with the Storyblok Service and add the component fields.

components/ui/Hero/Hero.tsx

import React, { FC } from 'react'
import StoryblokService from '@lib/storyblok'
import { Container } from '@components/ui'
import { RightArrow } from '@components/icons'
import { SbEditableContent } from 'storyblok-react'
import { DynamicSbEditable } from '@components/core'
import s from './Hero.module.css'
import Link from 'next/link'

interface Props {
  className?: string
  headline: string
  description: string
  blok?: SbEditableContent
}

const Hero: FC<Props> = ({ headline, description, blok }) => {
  const renderedDescription = (blok && StoryblokService.client.richTextResolver) ? StoryblokService.client.richTextResolver.render(blok.description) : description
  return (
    <DynamicSbEditable content={blok}>
      <div className="bg-black">
        <Container>
          <div className={s.root}>
              <h2 className="text-4xl leading-10 font-extrabold text-white sm:text-5xl sm:leading-none sm:tracking-tight lg:text-6xl">
                {blok ? blok.headline : headline}
              </h2>
              <div className="flex flex-col justify-between">
                <div className="mt-5 text-xl leading-7 text-accent-2 text-white" 
                dangerouslySetInnerHTML={
                  {__html: renderedDescription }
                } />
                <Link href={`/${blok ? blok.link.cached_url : ''}`}>
                  <a className="text-white pt-3 font-bold hover:underline flex flex-row cursor-pointer w-max-content">
                    Read it here
                    <RightArrow width="20" heigh="20" className="ml-1" />
                  </a>
                </Link>
              </div>
          </div>
        </Container>
      </div>
      </DynamicSbEditable>
  )
}

export default Hero

Since your Hero doesn't have any products, just enter the Storyblok content and wrap it in a DynamicSbEditable component. For the link, enter an internal link to the custom detailed product you will create at the end of this guide.

Storyblok editing capabilities

HomeAllProductsGrid.tsx

Finally, add your Storyblok content to the big grid component on the home page.

components/core/HomeAllProductsGrid/HomeAllProductsGrid.tsx

import { FC } from 'react'
import Link from 'next/link'
import { getCategoryPath, getDesignerPath } from '@lib/search'
import { SbEditableContent } from "storyblok-react";
import { Grid } from '@components/ui'
import { ProductCard } from '@components/product'
import { DynamicSbEditable } from '@components/core'
import s from './HomeAllProductsGrid.module.css'

interface Props {
  categories?: any
  brands?: any
  newestProducts?: any,
  blok?: SbEditableContent
}

const Head: FC<Props> = ({ categories = [], brands = [], newestProducts= [], blok }) => {
  const activeProducts = blok?.products ? blok.products.fetchedItems : newestProducts;
  const activeCategories = blok?.categories ? blok.categories.fetchedItems : categories;

  return (
    <div className={s.root}>
      <DynamicSbEditable content={blok}>
      {activeCategories || brands ? (<div className={s.asideWrapper}>
        <div className={s.aside}>
          {activeCategories && activeCategories.length ? (<ul className="mb-10">
            <li className="py-1 text-base font-bold tracking-wide">
              <Link href={getCategoryPath('')}>
                <a>All Categories</a>
              </Link>
            </li>
            {activeCategories.map((cat: any) => (
              <li key={cat.path} className="py-1 text-accents-8">
                <Link href={getCategoryPath(cat.path)}>
                  <a>{cat.name}</a>
                </Link>
              </li>
            ))}
          </ul>) : null }
          {brands && brands.length ? (<ul className="">
            <li className="py-1 text-base font-bold tracking-wide">
              <Link href={getDesignerPath('')}>
                <a>All Designers</a>
              </Link>
            </li>
            {brands.flatMap(({ node }: any) => (
              <li key={node.path} className="py-1 text-accents-8">
                <Link href={getDesignerPath(node.path)}>
                  <a>{node.name}</a>
                </Link>
              </li>
            ))}
          </ul>) : null }
        </div>
      </div>) : null}
      <div className="flex-1">
        <Grid layout="normal">
          {activeProducts && activeProducts.map(({ node }: any) => (
            <ProductCard
              key={node.path}
              product={node}
              variant="simple"
              imgWidth={480}
              imgHeight={480}
            />
          ))}
        </Grid>
      </div>
      </DynamicSbEditable>
    </div>
  )
}

export default Head

In the big grid component, you can select from a few categories {1} as well as a selection of products {2}, which you pass on to the component in Next.js. The nice thing here is that you can freely arrange the order of the categories or products from within Storyblok.

Grid Component

The final missing piece is to load everything with the Storyblok client. Open the Index Page file and copy the following code. First, you're loading your Home Story from Storyblok, then you will find all of the components that have a plugin defined and load the correct products or categories from the BigCommerce API and pass it along with the Storyblok data.

index.tsx

pages/index.tsx

import { useState, useEffect } from 'react'
import type { GetStaticPropsContext, InferGetStaticPropsType, NextPageContext } from 'next'
import { getConfig } from '@bigcommerce/storefront-data-hooks/api'
import getSiteInfo from '@bigcommerce/storefront-data-hooks/api/operations/get-site-info'
import getAllPages from '@bigcommerce/storefront-data-hooks/api/operations/get-all-pages'
import { Layout, DynamicComponent } from '@components/core'
import { SbEditableContent } from "storyblok-react";
import StoryblokService from '@lib/storyblok'
import getDetailsFromStory from '@lib/storyblokBigCommerce'

export async function getStaticProps({
  params,
  preview,
  locale,
}: GetStaticPropsContext) {
  if(params) StoryblokService.setQuery(params)
  if(preview) StoryblokService.devMode = true
  const config = getConfig({ locale })
  const { categories, brands } = await getSiteInfo({ config, preview })
  const { pages } = await getAllPages({ config, preview })
 
  const { data: { story }} = await StoryblokService.get('cdn/stories/home', {})
  const copyOfStory = Object.assign({}, story)
  const newContent = await getDetailsFromStory({ story: copyOfStory.content, config, preview })
  copyOfStory.content = newContent

  return {
    props: {
      categories,
      brands,
      pages,
      story: copyOfStory,
    },
    revalidate: 10,
  }
}


const nonNullable = (v: any) => v

export default function Home({
  story,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  const [storyContent, setStoryContent] = useState(story.content);

  useEffect(
    () => {
      StoryblokService.initEditor({ content: storyContent, setContent: setStoryContent })
    },
  )

  const components = storyContent.body.map((blok: SbEditableContent) => { 
    return (<DynamicComponent blok={blok} key={blok._uid} />)
  })

  return (
    <div>
      { components }
    </div>
  )
}


Home.Layout = Layout

Combining API Calls from Storyblok and BigCommerce

Since the basic data of which products to display is stored in Storyblok, but the details for all the products are stored in BigCommerce, you must fetch the data for each product from BigCommerce. To do that, create a new file in the lib folder lib/storyblokBigCommerce.ts to handle the combined logic. Add the fetched data from BigCommerce as an additional fetchedItems array to each plugin component.

lib/storyblokBigCommerce.ts

import getAllProducts from '@bigcommerce/storefront-data-hooks/api/operations/get-all-products'
import getSiteInfo from '@bigcommerce/storefront-data-hooks/api/operations/get-site-info'
import { SbEditableContent } from "storyblok-react";
import { BigcommerceConfig } from '@bigcommerce/storefront-data-hooks/api'

type detailsContext = {
    story?: SbEditableContent
    preview?: boolean
    config?: BigcommerceConfig
}

export default async function getDetailsFromStory({ story, config, preview }: detailsContext) {
    let storyCopy = Object.assign({}, story)
    const { categories } = await getSiteInfo({ config, preview })

    let finalPromises = await storyCopy.body.map(async (blok: SbEditableContent) => {
        const promises = await Object.keys(blok).map(async key => {
          // check all the entries for plugins and fetch the items
          if(blok[key].hasOwnProperty('plugin') && blok[key].plugin === 'big-commerce') {
              const type = blok[key].items[0].type
              const itemIds = blok[key].items.map((i: { id: any }) => i.id)
    
              if(type === 'product' ) {
                const fetchedItems = await getAllProducts({
                  variables: { entityIds: itemIds! },
                  config,
                  preview,
                })
                blok[key].fetchedItems = fetchedItems.products
                return fetchedItems
              } else if(type === 'category' ) {
                blok[key].fetchedItems = categories.filter(c => itemIds.includes(c.entityId))
              }
           }
        })
        return await Promise.all(promises)
    })
    await Promise.all(finalPromises)
    return storyCopy
}

This function then is called in your getStaticProps function in the index.tsx page.

export async function getStaticProps({
  preview,
  locale,
}: GetStaticPropsContext) {
...
const newContent = await getDetailsFromStory({ story: story.content, config, preview })
...
}

Activating the Storyblok Live Preview Editor

Inside the pages/index.tsx you can make use of React hooks to set the story object whenever you're changing something inside Storyblok, like when typing a heading. To do that, pass the hook callback to your initEditor function in the Storyblok Service.

pages/index.tsx

const [storyContent, setStoryContent] = useState(story.content);

  useEffect(
    () => {
      StoryblokService.initEditor({ content: storyContent, setContent: setStoryContent })
    },
  )

You're entering the current story content as well as the callback function to change that content to the initEditor function. Then whenever an input event happens, you get the content with Storyblok's addComments functions, which adds the _editable properties to the content. Then you can go through the previously fetched data and overwrite it with the changes in Storyblok. Finally, setting it with the setContent callback will result in a live update in the Visual Editor.

lib/storyblok.ts

initEditor({ content, setContent }: Editor) {
    if (window.storyblok) {
      window.storyblok.init()
      window.storyblok.on(['change', 'published'], () => location.reload(true))

      // this will alter the state and replaces the current story with a current raw story object (no resolved relations or links)
      const inputFunction = (event: any) => {
        if (event && event.story.content._uid === content._uid) { 
            const newContent: any =  window.storyblok.addComments(event.story.content, event.story.id)

            if(newContent.body) {
              // we need to track changes if bloks are rearranged in storyblok
              const mergedBody: any[] = newContent.body?.map((item: StoryblokComponent<string>) => {
                // and keep the old fetched data, that is not stored within storyblok
                let oldItem = content.body.find((itemWithData: StoryblokComponent<string>) => itemWithData._uid === item._uid)
                // we keep the old data and overwrite it with the new
                return merge(oldItem, item)
              }).filter(Boolean)

              newContent.body = mergedBody
            }
            setContent(newContent)
        }
      }

      // we will debounce the funcction since we're doing some data processing inside
      window.storyblok.on('input', debounce(inputFunction, 300))
    }
  }
Hint:

You can check out these changes here: Add Components and Index Page

If everything worked out you should be able to click {1} and live edit {2} the hero component and select products or categories on the other components. It should also be possible to reorder the components and products with a live preview.

NextJs Commerce Live Editing

Adding Next.js Preview Mode

In order to optimize the pages, you'll want to pre-render the pages at build time. This is great for performance, but not ideal for previews in Storyblok. For this reason, Next.js created a Preview-Mode feature. To add a preview mode, add these two files: pages/api/preview.ts and pages/api/exit-preview.ts.

pages/api/preview.ts

import StoryblokClient from '@lib/storyblok'
import { NextApiRequest, NextApiResponse } from "next";

export default async function preview(req: NextApiRequest, res: NextApiResponse) {
  // Check the secret and next parameters
  // This secret should only be known to this API route and the CMS
  if (
    req.query['_storyblok_tk[token]'] !== process.env.STORYBLOK_TOKEN ||
    !req.query['_storyblok']
  ) {
    return res.status(401).json({ message: 'Invalid token' })
  }

  // Fetch the headless CMS to check if the provided `slug` exists
  const { data: { story }} = await StoryblokClient.get(`cdn/stories/${req.query._storyblok}`, {})

  // If the slug doesn't exist prevent preview mode from being enabled
  if (!story) {
    return res.status(401).json({ message: 'Invalid slug' })
  }

  // Enable Preview Mode by setting the cookies
  res.setPreviewData({})

  // Redirect to the path from the fetched post
  // We don't redirect to req.query.slug as that might lead to open redirect vulnerabilities
  res.writeHead(307, { Location: story.path })
  res.end()
}

This preview path reloads a draft version from Storyblok and rerenders the page. If you deploy your example and open it {1}, you can see that the component is showing the deployed content {2} and not the changed content in Storyblok {3}. In the request object, enter the Storyblok parameters that are automatically attached when clicking the preview button {4}.

Preview Mode not active

Add a preview route to Storyblok by adding the api/preview path to the Settings {1} under Preview URLs {2} and click Save {3}.

Preview Route

If you open this route {1} from within Storyblok now, you'll see the message:Invalid Token{2}. Click the preview mode button {3}. Since Storyblok generates its own token here for the preview, you need to replace the _storyblok_tk[token] parameter in the URL with the token you have defined for Storyblok as STORYBLOK_TOKEN in the env file and reload. Then you should be able to see the preview content.

Storyblok editing capabilities

Once this route is called with the correct token, it should redirect to the page with the changed content. Now when you reload the page in Storyblok, the Next.js preview cookies should be set and you'll be in preview mode. So the current version is saved, but deployed content will not yet appear.

Preview Mode active

You also need to add an exit-preview route to leave the preview mode so that you can see the deployed content again. Just call the api/exit-preview route to leave preview mode.

pages/api/exit-preview.ts

import { NextApiRequest, NextApiResponse } from "next";

export default async function exit(_: NextApiRequest, res: NextApiResponse) {
    // Exit the current user from "Preview Mode". This function accepts no args.
    res.clearPreviewData()
  
    // Redirect the user back to the index page.
    res.writeHead(307, { Location: '/' })
    res.end()
}

You can also add it to your Dev Previews in the settings {1} for quick access to exiting the preview mode {2}.

Storyblok editing capabilities
Hint:

You can check these changes in the commit Add Preview Mode

Custom Product Detail Page

You may want to create custom pages for specific products that have some additional content like on the Featured Product in the Demo example, where you add a custom heading and description.

To do this, first set up a folder called product {1} to allow for custom Product Detail Pages. Then create a new entry {2} with the matching slug {4} and a Product content type {5} as described in the storefront setup tutorial.

Detai Product

If you open this entry now and the product with this slug also exists 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}.

Product Detail Page

In order for this Storyblok content to show up on your product pages, you need to adapt your Product pages pages/product/[slug].tsx. First load the Storyblok Content if there is any in your getStaticProps function. If a product exists in Storyblok, you will pass the story content to the ProductView component.

pages/product/[slug].tsx

import type {
  GetStaticPathsContext,
  GetStaticPropsContext,
  InferGetStaticPropsType,
} from 'next'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import { getConfig } from '@bigcommerce/storefront-data-hooks/api'
import getAllPages from '@bigcommerce/storefront-data-hooks/api/operations/get-all-pages'
import getProduct from '@bigcommerce/storefront-data-hooks/api/operations/get-product'
import { Layout } from '@components/core'
import { ProductView } from '@components/product'
import getAllProductPaths from '@bigcommerce/storefront-data-hooks/api/operations/get-all-product-paths'
import StoryblokService from '@lib/storyblok'

export async function getStaticProps({
  params,
  locale,
  preview,
}: GetStaticPropsContext<{ slug: string }>) {
  let story : any = false;
  const config = getConfig({ locale })

  const { pages } = await getAllPages({ config, preview })
  const { product } = await getProduct({
    variables: { slug: params!.slug },
    config,
    preview,
  })

  try {
    const { data } = await StoryblokService.get(`cdn/stories/product/${params!.slug}`, {})
    if(data.story) story = data.story
  } catch(e) {
    console.error(`Product ${params!.slug} doesn't exist in Storyblok`)
  }

  if (!product) {
    throw new Error(`Product with slug '${params!.slug}' not found`)
  }

  return {
    props: { pages, product, story: story },
    revalidate: 200,
  }
}

export async function getStaticPaths({ locales }: GetStaticPathsContext) {
  const { products } = await getAllProductPaths()

  return {
    paths: locales
      ? locales.reduce<string[]>((arr, locale) => {
          // Add a product path for every locale
          products.forEach((product) => {
            arr.push(`/${locale}/product${product.node.path}`)
          })
          return arr
        }, [])
      : products.map((product) => `/product${product.node.path}`),
    // If your store has tons of products, enable fallback mode to improve build times!
    fallback: false,
  }
}

export default function Slug({
  product, story
}: InferGetStaticPropsType<typeof getStaticProps>) {
  const router = useRouter()
  const hasStory = story ? story.content : false
  const [storyContent, setStoryContent] = useState(hasStory);
  
  if(hasStory) {
    useEffect(
      () => {
        StoryblokService.initEditor({ content: storyContent, setContent: setStoryContent })
      },
    )
  }

  return router.isFallback ? (<h1>Loading...</h1>) : (
    <ProductView product={product} story={storyContent} />
  )
}

Slug.Layout = Layout

Then you also need to adapt the ProductView component to load the Story data if any exists, otherwise it will load the data from BigCommerce. You will also add the DynamicSbEditable to enable the live-preview.

components/product/ProductView/ProductView.tsx

import { FC, useState } from 'react'
import cn from 'classnames'
import Image from 'next/image'
import { NextSeo } from 'next-seo'

import s from './ProductView.module.css'
import { useUI } from '@components/ui/context'
import { Swatch, ProductSlider } from '@components/product'
import { Button, Container } from '@components/ui'
import { HTMLContent } from '@components/core'

import usePrice from '@bigcommerce/storefront-data-hooks/use-price'
import useAddItem from '@bigcommerce/storefront-data-hooks/cart/use-add-item'
import type { ProductNode } from '@bigcommerce/storefront-data-hooks/api/operations/get-product'
import StoryblokService from '@lib/storyblok'
import { DynamicSbEditable } from '@components/core'

import {
  getCurrentVariant,
  getProductOptions,
  SelectedOptions,
} from '../helpers'
import WishlistButton from '@components/wishlist/WishlistButton'

interface Props {
  className?: string
  children?: any
  product: ProductNode,
  story: any | boolean,
}

const ProductView: FC<Props> = ({ product, story }) => {
  const addItem = useAddItem()
  const { price } = usePrice({
    amount: product.prices?.price?.value,
    baseAmount: product.prices?.retailPrice?.value,
    currencyCode: product.prices?.price?.currencyCode!,
  })
  const { openSidebar } = useUI()
  const options = getProductOptions(product)
  const [loading, setLoading] = useState(false)
  const [choices, setChoices] = useState<SelectedOptions>({
    size: null,
    color: null,
  })
  const variant =
    getCurrentVariant(product, choices) || product.variants.edges?.[0]

  const addToCart = async () => {
    setLoading(true)
    try {
      await addItem({
        productId: product.entityId,
        variantId: product.variants.edges?.[0]?.node.entityId!,
      })
      openSidebar()
      setLoading(false)
    } catch (err) {
      setLoading(false)
    }
  }

  return (
    <DynamicSbEditable content={story}>
    <Container className="max-w-none w-full" clean>
      <NextSeo
        title={product.name}
        description={product.description}
        openGraph={{
          type: 'website',
          title: product.name,
          description: product.description,
          images: [
            {
              url: product.images.edges?.[0]?.node.urlOriginal!,
              width: 800,
              height: 600,
              alt: product.name,
            },
          ],
        }}
      />
      <div className={cn(s.root, 'fit')}>
        <div className={cn(s.productDisplay, 'fit')}>
          <div className={s.nameBox}>
            <h1 className={s.name}>{story && story.name.length ? story.name : product.name }</h1>
            <div className={s.price}>
              {price}
              {` `}
              {product.prices?.price.currencyCode}
            </div>
          </div>

          <div className={s.sliderContainer}>
            <ProductSlider>
              {product.images.edges?.map((image, i) => (
                <div key={image?.node.urlOriginal} className={s.imageContainer}>
                  <Image
                    className={s.img}
                    src={image?.node.urlOriginal!}
                    alt={image?.node.altText || 'Product Image'}
                    width={1050}
                    height={1050}
                    priority={i === 0}
                    quality="85"
                  />
                </div>
              ))}
            </ProductSlider>
          </div>
        </div>

        <div className={s.sidebar}>
          <section>
            {options?.map((opt: any) => (
              <div className="pb-4" key={opt.displayName}>
                <h2 className="uppercase font-medium">{opt.displayName}</h2>
                <div className="flex flex-row py-4">
                  {opt.values.map((v: any, i: number) => {
                    const active = (choices as any)[opt.displayName]

                    return (
                      <Swatch
                        key={`${v.entityId}-${i}`}
                        active={v.label === active}
                        variant={opt.displayName}
                        color={v.hexColors ? v.hexColors[0] : ''}
                        label={v.label}
                        onClick={() => {
                          setChoices((choices) => {
                            return {
                              ...choices,
                              [opt.displayName]: v.label,
                            }
                          })
                        }}
                      />
                    )
                  })}
                </div>
              </div>
            ))}

            <div className="pb-14 break-words w-full max-w-xl">
              { story  && story.description && story.description.content[0].content
              ? (<HTMLContent html={StoryblokService.client.richTextResolver.render(story.description)}/>)
              : (<HTMLContent html={product.description} />)
              }
             
            </div>
          </section>
          <div>
            <Button
              aria-label="Add to Cart"
              type="button"
              className={s.button}
              onClick={addToCart}
              loading={loading}
              disabled={!variant}
            >
              Add to Cart
            </Button>
          </div>
        </div>

        <WishlistButton
          className={s.wishlistButton}
          productId={product.entityId}
          variant={product.variants.edges?.[0]!}
        />
      </div>
    </Container>
    </DynamicSbEditable>
  )
}

export default ProductView

With this setup, you should be able to add custom content to any product you want. This is great for adding content that you don't want or need in the eCommerce system but do need for the Storefront. A good example of this would be layout options or the order of how products appear.

Hint:

You can check these changes in the commit Custom Product Detail

By connecting two headless systems you gain a very flexible structure and can reorder any content with Storyblok’s powerful visual editor. It also allows you to decouple the visual logic from your eCommerce system.

Reorder Components

Deployment

Deploy your Storefront with the Vercels. To use it you need to install the CLI tool by vercel and sign up for an account.

npm i -g vercel

Once it is installed and you're logged in, you can deploy this directory to production with the following command:

vercel --prod

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.