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.5
  • 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. Once you're signed up you have two options: Option A: you clone our storefront template with the content structure already set up. Or Option B: you create it by yourself to learn how to set up different content structure.

A: Clone the Storefront Template

To duplicate the template we set up with this tutorial, click the following button:

Storyblok editing capabilities

B: Create Storefront from Scratch

Once you're logged in, 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 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

This step is only necessary if you haven't duplicated our example space. 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.

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. You can follow all these changes, by looking at the commits of the example repository. 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 File

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 preview token. Your Storyblok preview token is automatically read from the .env.local file.

lib/storyblok.ts

import StoryblokClient from 'storyblok-js-client'

const Storyblok = new StoryblokClient({
    accessToken: process.env.STORYBLOK_TOKEN,
    cache: {
      clear: 'auto',
      type: 'memory'
    }
})

export default Storyblok


Adding the Storyblok Bridge and Passing the Cache Version

To add the Storyblok Bridge, we will add a custom hook to thelib/storyblok.ts file. The useStoryblok hook adds the necessary <script> tag and loads the event listeners. Read more about this in our 5 minutes Next.js tutorial.

components/core/Head.tsx

import StoryblokClient, { StoryblokComponent, StoryData } from 'storyblok-js-client'
import { useEffect, useState } from 'react'
import { merge, debounce } from 'lodash'

const Storyblok = new StoryblokClient({
    accessToken: process.env.STORYBLOK_TOKEN,
    cache: {
      clear: 'auto',
      type: 'memory'
    }
})

function addBridge(callback: any) {
  const existingScript = document.getElementById("storyblokBridge");
  if (!existingScript) {
    const script = document.createElement("script");
    script.src = `https://app.storyblok.com/f/storyblok-latest.js`;
    script.id = "storyblokBridge";
    document.body.appendChild(script);
    script.onload = () => {
      // once the scrip is loaded, init the event listeners
      callback();
    };
  } else {
      callback();
  }
}

function initEventListeners(story: StoryData, setStory: any) {
  if (window.storyblok) {
    window.storyblok.init({
      accessToken: process.env.STORYBLOK_TOKEN
    });
    
    window.storyblok.on(["change", "published"], () => location.reload(true));

  const inputFunction = (event: any, s: any) => {
    const content = s.content ? s.content : s
    if (event && event.story.content._uid === content._uid) { 
        event.story.content =  window.storyblok.addComments(event.story.content, event.story.id)

        if(event.story.content.body) {
          const mergedBody: any[] = event.story.content.body?.map((item: StoryblokComponent<string>) => {
            let oldItem = content.body.find((itemWithData: StoryblokComponent<string>) => itemWithData._uid === item._uid)
            return merge(oldItem, item)
          }).filter(Boolean)

          event.story.content.body = mergedBody
        }
        setStory(event.story)
    }
  }

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

export function useStoryblok(originalStory: StoryData) {
    let [story, setStory] = useState(originalStory);
  
    useEffect(() => {
      // first load the bridge, then initialize the event listeners
      addBridge(() => initEventListeners(story, setStory));
    });
  
    return story;
  }

export default Storyblok

The event listeners in the initEventListeners function above make use of React Hooks to return the content that was changed during live editing. The function for live input is slightly adapted to what is normally used in Next.js projects. In this case 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.

Hint:

You can check these changes in the commit Add Storyblok

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/common/DynamicComponent with two new files: a DynamicComponent.tsx and a index.tsx file.

components/common/DynamicComponent/DynamicComponent.tsx

import React, { FC } from 'react'
import { Grid, Marquee, Hero } from '@components/ui'
import HomeAllProductsGrid from '@components/common/HomeAllProductsGrid'
import SbEditable, { 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,
  'hero': 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 (<SbEditable content={blok} key={blok._uid}><FoundComponent blok={blok} /></SbEditable>)
      } else {
        return (<p>{componentName} is not yet defined.</p>)
      }
    }
    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'

To be able to load the component you also need to add it to the core component file. Add the DynamicComponent to components/common/index.ts:

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

You can check these changes in the commit add Storyblok & dynamic components

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 use the Storyblok content if it's available because it was passed into the component through the DynamicComponent. 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'
interface Props {
  className?: string
  children?: ReactNode[] | Component[] | any[]
  layout?: 'A' | 'B' | 'C' | 'D' | 'normal'
  products?: ReactNode[] | Component[] | any[]
  variant?: 'default' | 'filled'
  blok?: SbEditableContent
}

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}>
    { 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}
            />
          )) : null }
  </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 Ticker from 'react-ticker'
import { ProductCard } from '@components/product'
import { SbEditableContent } from "storyblok-react"
interface Props {
  className?: string
  children?: ReactNode[] | Component[] | any[]
  variant?: 'primary' | 'secondary'
  blok?: SbEditableContent
  products?: ReactNode[] | Component[] | any[]
}

const Marquee: FC<Props> = ({
  className = '',
  children,
  products,
  variant = 'primary',
  blok
}) => {
  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 (
    <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>
  )
}

export default Marquee

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 client and add the component fields.

components/ui/Hero/Hero.tsx

import React, { FC } from 'react'
import Storyblok from '@lib/storyblok'
import { SbEditableContent } from 'storyblok-react'
import { Container } from '@components/ui'
import { RightArrow } from '@components/icons'
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 && Storyblok.richTextResolver) ? Storyblok.richTextResolver.render(blok.description) : description
  const bg = blok?.color ? blok.color.color : '#000'
  return ( 
    <div style={{ backgroundColor: bg }}>
      <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?.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="/blog">
              <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>
  )
}

export default Hero
Storyblok editing capabilities

HomeAllProductsGrid.tsx

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

components/common/HomeAllProductsGrid/HomeAllProductsGrid.tsx

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

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

const Head: FC<Props> = ({ categories, brands, newestProducts, blok }) => {
  const activeProducts = blok?.products ? 
    blok?.products?.items.map((sbProduct: any) => 
      blok?.products?.fetchedItems?.find(({node}:any) => 
      node.entityId === sbProduct.id)).filter(Boolean)  
    : newestProducts;
  const activeCategories = blok?.products ? 
    blok?.categories?.items.map((sbCategory: any) => 
      blok?.categories?.fetchedItems?.find((category:any) => 
      category.entityId === sbCategory.id)).filter(Boolean)  
    : categories;

  return (
    <div className={s.root}>
      <div className={s.asideWrapper}>
        <div className={s.aside}>
          <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 text-base">
                <Link href={getCategoryPath(cat.path)}>
                  <a>{cat.name}</a>
                </Link>
              </li>
            ))}
          </ul>
          {(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>
      <div className="flex-1">
        <Grid layout="normal">
          {activeProducts.map(({ node }: any) => (
            <ProductCard
              key={node.path}
              product={node}
              variant="simple"
              imgWidth={480}
              imgHeight={480}
            />
          ))}
        </Grid>
      </div>
    </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 { Layout, DynamicComponent } from '@components/common'
import type { GetStaticPropsContext, InferGetStaticPropsType } from 'next'
import { SbEditableContent } from "storyblok-react"
import Storyblok, { useStoryblok } from '@lib/storyblok'
import getDetailsFromStory from '@lib/storyblokBigCommerce'


import { getConfig } from '@framework/api'

export async function getStaticProps({
  preview,
  locale,
}: GetStaticPropsContext) {
  const config = getConfig({ locale })

  const sbParams = {
    version: "draft"
  }
 
  const { data: { story }} = await Storyblok.get('cdn/stories/home', sbParams)
  const copyOfStory = Object.assign({}, story)
  const fullProducts = await getDetailsFromStory({ story, config, preview })
  copyOfStory.content = fullProducts

  return {
    props: {
      story: copyOfStory,
    },
    revalidate: 14400,
  }
}

const nonNullable = (v: any) => v

export default function Home({
  story,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  const liveStory = useStoryblok(story);

  const components = liveStory.content.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 '@framework/api/operations/get-all-products'
import getSiteInfo from '@framework/api/operations/get-site-info'
import { BigcommerceConfig } from '@framework/api'
import { SbEditableContent } from "storyblok-react"
import { StoryData } from 'storyblok-js-client'

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

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

    let finalPromises = await storyContent.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 === 'sb-bigcommerce') {
              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: { entityId: any; }) => itemIds.includes(c.entityId))
              }
           }
        })
        return await Promise.all(promises)
    })
    await Promise.all(finalPromises)
    return storyContent
}

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

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

Activating the Storyblok Live Preview Editor

Inside the pages/index.tsx you can make use of our custom Storyblok hook in lib/storyblok.ts to set the story object whenever you're changing something inside Storyblok, like when typing a heading.

pages/index.tsx

export default function Home({
  story,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  const liveStory = useStoryblok(story);

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

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

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

function initEventListeners(story: StoryData, setStory: any) {
  if (window.storyblok) {
    // this initiliazies the bridge
    window.storyblok.init({
      accessToken: process.env.STORYBLOK_TOKEN
    });
    
    // this reloads the page when we hit save
    window.storyblok.on(["change", "published"], () => location.reload(true));

  // this function is called whenever an input happens in Storyblok
  const inputFunction = (event: any, s: any) => {
    const content = s.content ? s.content : s
    if (event && event.story.content._uid === content._uid) { 
        event.story.content =  window.storyblok.addComments(event.story.content, event.story.id)

        if(event.story.content.body) {
          const mergedBody: any[] = event.story.content.body?.map((item: StoryblokComponent<string>) => {
            let oldItem = content.body.find((itemWithData: StoryblokComponent<string>) => itemWithData._uid === item._uid)
            return merge(oldItem, item)
          }).filter(Boolean)

          event.story.content.body = mergedBody
        }
        setStory(event.story)
    }
  }

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

You can check out these changes in this commit: add Storyblok & dynamic components

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. For the preview route, you will need to set up a MY_SECRET_TOKEN environment variable to protect it from being previewed by anyone.

pages/api/preview.ts

import { NextApiRequest, NextApiResponse } from "next";

export default async function preview(req: NextApiRequest, res: NextApiResponse) {
  // Check the secret and next parameters
  if (
    req.query.secret !== process.env.MY_SECRET_TOKEN ||
    !req.query.slug
  ) {
    return res.status(401).json({ message: 'Invalid token' })
  }

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

  // Set cookie to None, so it can be read in the Storyblok iframe
  const cookies = res.getHeader('Set-Cookie')
  if(cookies instanceof Array) {
    res.setHeader('Set-Cookie', cookies?.map((cookie: string) => cookie.replace('SameSite=Lax', 'SameSite=None')))
 }

  // Redirect to the entry location
  let slug = req.query.slug

  // Handle home slug 
  if(slug === 'home') {
      slug = ''
  }

  // Redirect to the path from entry
  res.redirect(`/${slug}`)
}

This preview path sets the preview cookie 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 and pass a parameter slug and the secret token: https://my-example.vercel.app/api/preview?slug=home&token=MY_SECRET_TOKEN you shoul be in the preview mode.

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()

     // set the cookies to None
     const cookies = res.getHeader('Set-Cookie')
     if(cookies instanceof Array) {
        res.setHeader('Set-Cookie', cookies?.map((cookie: string) => cookie.replace('SameSite=Lax', 'SameSite=None')))
     }
 
    // Redirect the user back to the index page.
    res.redirect('/')
}

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

For a more detailed explanation on how to load different versions from Storyblok while in Preview mode, read this tutorial.

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 { useRouter } from 'next/router'
import { Layout } from '@components/common'
import { ProductView } from '@components/product'

// Data

import { getConfig } from '@framework/api'
import getProduct from '@framework/api/operations/get-product'
import getAllPages from '@framework/api/operations/get-all-pages'
import getAllProductPaths from '@framework/api/operations/get-all-product-paths'
import Storyblok, { useStoryblok } from '@lib/storyblok'
import SbEditable from "storyblok-react"

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

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

  const sbParams = {
    version: "draft"
  }

  try {
    const { data } = await Storyblok.get(`cdn/stories/product/${params!.slug}`, sbParams)
    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 },
    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}`),
    fallback: 'blocking',
  }
}

export default function Slug({
  product,
  story
}: InferGetStaticPropsType<typeof getStaticProps>) {
  // @ts-ignore 
  const liveStory = useStoryblok(story) 
  const router = useRouter()

  const hasStory = typeof liveStory.content !== 'undefined'

  if(router.isFallback) {
    return (<h1>Loading...</h1>)
  } else if (hasStory) {
    return (
      <SbEditable content={liveStory.content} key={liveStory.content._uid}>
        <ProductView product={product} story={liveStory.content} />
      </SbEditable>
    )
  }

  return (<ProductView product={product} />)
}

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 SbEditable to enable the live-editing.

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, Text } from '@components/ui'

import usePrice from '@framework/use-price'
import useAddItem from '@framework/cart/use-add-item'
import type { ProductNode } from '@framework/api/operations/get-product'
import {
  getCurrentVariant,
  getProductOptions,
  SelectedOptions,
} from '../helpers'
import WishlistButton from '@components/wishlist/WishlistButton'
import Storyblok from '@lib/storyblok'
interface Props {
  className?: string
  children?: any
  product: ProductNode
  story?: any

}

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)

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

  return (
    <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 key={product.entityId}>
              {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
              ? (<Text html={Storyblok.richTextResolver.render(story.description)}/>)
              : (<Text 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={variant!}
        />
      </div>
    </Container>
  )
}

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 create custom product page

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.