How to develop on Next.js Commerce: an in-depth guide

Authors
Last updated
 

Next.js Commerce is a modern, performant, production-ready framework for building (headless) commerce storefronts. It supports a number of major commerce platforms out of the box, including Shopify, BigCommerce, Swell, Saleor, Commerce.js, and more. We at Plasmic have been building a visual editing experience that tightly integrates with it, and wanted to share what we’ve learned.

This is an in-depth guide to navigating and extending the codebase. If you instead want a tutorial on getting started with it, check out our Next.js Commerce tutorial.

Background on Next.js Commerce

What is noteworthy about Next.js Commerce is that it takes on the ambitious goal of implementing a common interface across all of these platforms. By abstracting away the differences between the various platforms, you can reuse this same consistent interface and build features, packages, components, and entire sites that will “Just Work” for any given provider.

This has a number of concrete benefits:

  1. This gives the ecosystem a single rallying point—besides Vercel, multiple commerce platform providers have contributed directly to this codebase.
  2. This gives your business and your development team a single API to invest in and build on top of, while maintaining storefront portability and independence from the vendor. We at Plasmic have seen multiple brands migrate platforms—it happens, and having that flexibility is a non-theoretical benefit.
  3. This abstraction is especially relevant for agencies that want to work across multiple commerce platforms and build up their own tech stack accordingly.

However, inevitably, for any given provider, you will find that you will need to implement features and API integrations with the platform that are not built-in or available in the common interface. The amount of indirection can make the codebase slightly intimidating to dive into for the first time, but once you understand just a bit of foundation, you’ll be able to be productive.

Background on Plasmic

Landing pages and marketing content are a significant part of growing commerce storefronts, which is why page builders are widely used on platforms like Shopify.

Plasmic brings the same thing to Next.js—it is a visual editor that lets non-developers build landing pages and any other content on your website, optionally using the React components from your codebase as building blocks. This helps eliminate both developer workload and code, freeing up the dev team to work on core features.

Plasmic is used in commerce storefronts by brands like Yours and Beard Struggle. It has particularly deep integration with Next.js Commerce—you can drag and drop product data from your Next.js Commerce stores directly. Content editors can publish fully bespoke content and page designs that include dynamic collections of product catalog data, without a single line of code.

As we’ll see later, Plasmic can greatly accelerate building out a complete storefront. Without further ado, let’s dive into the architecture of Next.js Commerce!

Providers

Support for a commerce backend is implemented in a provider. (Note that various parts of the code still call this by its old name, framework.) All providers implement a common interface, called the Features API. Application code that’s built on top of this API is portable across providers.

This is how the Next.js Commerce codebase is organized, at a glance—it is a monorepo managed by TurboRepo:

  • packages/
    • commerce/: This defines the common interface, along with helpers and functions that serve as the base for all other providers.
    • local/: A provider for a special “fake” backend, which is simply powered by some local hard-coded JSON.
    • shopify/: A provider for Shopify.
    • bigcommerce/: A provider for BigCommerce.
    • (…other providers…)
  • site/: This is the actual example storefront website, built on Next.js. It specifies a single provider package that it depends on.

You choose which provider you want to use by specifying COMMERCE_PROVIDER in your site/.env.local, along with the associated credentials. For instance, for Shopify:

COMMERCE_PROVIDER=shopify
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=xxxxxxx.myshopify.com

Setting COMMERCE_PROVIDER actually changes your import paths! Now,

import '@framework/...'

actually imports from the packages/COMMERCE_PROVIDER that you chose.

The way this happens is through a bit of startup time Next-configuration magic that starts in your next.config.js. It loads a corresponding next.config.js from the selected platform, and also updates your tsconfig.json to specify the correct import paths.

Providers and features

Each provider implements a common API, called the Features API.

Not all providers have all features. Some providers may not have catalog search capabilities, and others may not have customer auth.

Each provider specifies what features it supports in its packages/PROVIDER/src/commerce.config.json file. Here is an example that lists all the available features:

{
  "features": {
    "cart": true,
    "search": true,
    "wishlist": false,
    "customerAuth": true,
    "customCheckout": true
  }
}

It’s up to the app to gracefully degrade based on what’s available from the currently active commerce provider, but normally you won’t need to worry about this if you’re just developing a storefront with a single commerce platform in mind. As we’ll see later, you may even choose to remove the directory for all but your selected provider.

The Features API

There are two different parts to the Features API:

  • Client-side: these are made available as hooks.
  • Server-side: in the code, these are called “API operations”, or also referred to as the “Node.js API.”

Each provider provides two standard entrypoints:

  • packages/PROVIDER/provider.ts defines the hooks.
  • packages/PROVIDER/api/index.ts defines the API operations.

As an example, if you want to build a page that lists all products from a collection, you can use the useSearch hook:

  const { data } = useSearch({
    search: '',
    categoryId: someCategoryId
    sort: sortStr,
  })

  return (
    <Grid layout="normal">
      {data.products.map((product) => (
        <ProductCard key={product.path} product={product} />
      ))}
    </Grid>
  )

As another example, if you want to list all the available collections that are defined in your Shopify store:

export async function getStaticProps({ preview, locale, locales }: GetStaticPropsContext) {
  const config = { locale, locales }
  const siteInfoPromise = commerce.getSiteInfo({ config })
  const { categories } = await siteInfoPromise

  return {
    props: { pages, categories },
  }
}

Hooks vs. operations

The first thing to notice is the separation between what’s available as hooks and what’s available as API operations.

The available hooks are well-documented.

However, the API operations are not documented:

  • login
  • getAllPages
  • getPage
  • getSiteInfo
  • getCustomerWishlist
  • getAllProductPaths
  • getAllProducts
  • getProduct

This distinction means there are things you’ll be able to do only client-side or only server-side. For instance, you cannot execute a search query server-side.

We’ll come back to how to deal with this in your code.

Normalization

The useSearch hook and the getProduct API operations could be providing data for any data provider, whether Shopify or others. The data provided is therefore normalized to a common form.

As mentioned further above as well, besides exposing a common set of functions, the API also exposes common types for the data. This ensures all providers speak the same language.

Note that this means terms may differ from the native vocabulary of the platform. For instance, you may have spotted from the earlier code snippet that “collections” in Shopify are called “categories” in the Features API.

Where does the magic happen for this normalization? By convention, most providers have normalize*() functions under packages/PROVIDER/src/util/. This is an important place you’ll want to be able to refer to, in order to really understand how the mapping works (such as the relationship between categories and Shopify collections).

Note also, there are inevitably things that are platform-specific that leak through in this abstraction, such as what the sort string can be.

Extending the API

Frequently, in order to build a more fully-featured, complex storefront, you will need to extend the platform.

For instance, you need specific parts of your upstream ecommerce platform’s APIs that are not built into the Features API.

Example: fetching custom Shopify data

As a concrete example, if you’re working on a Shopify site, you may want to fetch your blog posts that were authored in the Shopify platform’s blogging feature, or fetch additional custom “metafields” defined on your product. None of this is exposed in the Features API!

In general, you should be prepared to bite the bullet and start hacking on the code in packages/, rather than treat it as some immutable upstream dependency to avoid forking. And furthermore, be prepared to introduce some provider-specific changes into the core commerce package.

You’ll want to extend the Product type in the core packages/commerce/ with the notion of metafields.

export type Product = {
  id: string
  name: string
  description: string
  descriptionHtml?: string
  sku?: string
  slug?: string
  path?: string
  images: ProductImage[]
  variants: ProductVariant[]
  price: ProductPrice
  options: ProductOption[]

  // New fields
  metafields: Metafield[]
}

Then, update normalizeProduct() in packages/shopify/src/utils/normalize.ts to forward metafields from the GraphQL results appropriately.

export function normalizeProduct({
  id,
  title: name,
  vendor,
  images,
  variants,
  description,
  descriptionHtml,
  handle,
  priceRange,
  options,
  metafields,
  ...rest
}: ShopifyProduct): Product {
  return {
    id,
    name,
    vendor,
    path: `/${handle}`,
    slug: handle?.replace(/^\/+|\/+$/g, ''),
    price: money(priceRange?.minVariantPrice),
    images: normalizeProductImages(images),
    variants: variants ? normalizeProductVariants(variants) : [],
    options: options
      ? options
          .filter((o) => o.name !== 'Title') // By default Shopify adds a 'Title' name when there's only one option. We don't need it. https://community.shopify.com/c/Shopify-APIs-SDKs/Adding-new-product-variant-is-automatically-adding-quot-Default/td-p/358095
          .map((o) => normalizeProductOption(o))
      : [],
    // Forward the metafields
    metafields: metafields?.edges.map((edge) => edge.node) ?? [],
    ...(description && { description }),
    ...(descriptionHtml && { descriptionHtml }),
    ...rest,
  }
}

You won’t need to worry about breaking the others since there is no type-checking performed on the others—in fact, we’d recommend removing the other provider directories altogether for any specific project, to reduce the clutter.

However, the indirection may be a tedious ongoing tax. The indirection can weave through multiple layers of functions, modules, and TypeScript types, which makes framework code intimidating to acquaint yourself with initially!

You can consider relocating code to exist within your site/ app and doing without the normalization layer. The tradeoff is that this does negate the platform independence and flexibility mentioned at the start—re-platforming is rare, though, and you can always bite that bullet if and when you get there rather than pay for the insurance now.

Example: server-side collection fetching

Say you want to work on product listing pages for the various categories/collections in your store.

Notice first that, while you can obtain the available categories from getSiteInfo, there are no specific APIs for listing all products in a given category/collection.

With the above API, the only way to do this is by using the useSearch hook. This means that this functionality is currently only available client-side—in effect, all product listing pages will need to be client-side rendered. This may be sufficient for your needs, but maybe not if you want to ensure your PLPs also receive the SSR treatment for performance, SEO, etc.

If your product catalog is sufficiently small, you can rely on the getAllProducts API operation to generate these pages and even implement SSR search. But otherwise, you would need to add a new API method that loads exactly the products you need.

On this specific example, there is in fact a trick to make any SWR fetch available server-side. You can use a library called react-ssr-prepass to get SSR-friendly component-level data fetching in Next.js today. Then you will need to switch your react-swr fetches to operate in Suspense mode.

(Plasmic has these server-side-friendly data-fetching functionality from Next.js Commerce built into the platform, which is how you are able to drag and drop arbitrary product data into your pages.)

Considerations when extending

In short, the main things to be aware of when extending the API are:

  • You will encounter indirection. This happens at the module level, at the type level, and at the

Data-fetching infrastructure

All data-fetching in the framework is built on top of react-swr.

However, most of the client-side hooks are expressed on top of some custom fetching infrastructure, which ultimately calls into react-swr under the hood.

Here is an annotated example for the useSearch hook.

export const handler: SWRHook<SearchProductsHook> = {
  // These are the default `options` that are passed to the fetcher below.
  fetchOptions: {
    query: getAllProductsQuery,
  },
  // This is what executes the fetch operation.
  async fetcher({ input, options, fetch }) {
    // ... the actual fetching code ...
  },
  // Here is some additional SWR-specific behavior that we are specifying in a format that is custom to this fetching infrastructure, such as the invalidation key.
  useHook: ({ useData }) => (input = {}) => {
    return useData({
      input: [
        ['search', input.search],
        ['categoryId', input.categoryId],
        ['brandId', input.brandId],
        ['sort', input.sort],
        ['locale', input.locale],
      ],
      swrOptions: {
        revalidateOnFocus: false,
        ...input.swrOptions,
      },
    })
  },
}

It is worth becoming familiar with this fetching infrastructure to enable changes and additions to any of the platform-specific hooks.

Roadmap toward a complete storefront

Next.js Commerce is a great starting point for a new ecommerce platform, and the code it comes with out of the box will be sufficient for small sites.

But many sites that turn to running their own headless stacks will inevitably grow to have a long list of requirements. Here are some of the things you may want to plan on adding, which are not included out-of-the-box in Next.js Commerce:

  • Pagination to support larger product catalogs.
  • A CMS, such as Plasmic’s CMS or a third-party headless CMS like Sanity.io or Contentful, so that content editors can easily update content throughout the site.
  • A page builder, such as Plasmic, so that non-developers can easily add new landing pages and other content.
  • A/B testing and personalization, which are important for growth.
  • Multi-country/currency support: guess the user’s country and allow them to select a correct location if necessary. (Note that this is distinct from language localization!) Vercel and Cloudflare provide geolocation info in requests.
  • Filtering and sorting, to enable more standard product listing page functionality.
  • Inventory checks, to determine if things are in-stock.
  • More integrations: product reviews, email marketing, exit intent offers, and many more common staples of storefronts.
  • The “usual concerns,” such as production monitoring, testing, continuous quality and performance checks, analytics, and much more.
  • Extra features like PWA support for offline shopping/homescreen installation, alternate checkout flows like the Shopify Buy SDK, etc.

While it can be a lot of work to build a full production storefront, the Plasmic visual page builder + CMS platform can greatly accelerate both time-to-market and ongoing growth iteration with its A/B testing and personalization suite.

Visually building storefronts, fast

Beyond letting non-developers build landing pages, Plasmic has a Next.js Commerce starter codebase with Plasmic fully integrated, so editors can easily drag-and-drop dynamic product data from your store into Plasmic pages. And these are more performant than what you get out of the box with Next.js Commerce, performing all data-loading server-side or statically.

Beyond allowing the entire team to collaborate and eliminating developer time, it comes with Next.js Commerce components built-in, such that you can easily drag-and-drop product data into the pages you build with Plasmic. And these are more performant than what you get out of the box with Next.js Commerce, performing all data-loading server-side or statically.

Plus, Plasmic includes a powerful optimization layer that makes it trivial for the marketing team to execute A/B testing, personalize content to different segments, schedule content changes, and much more.

For a step-by-step guide to getting started with Next.js Commerce + Plasmic, check out the full Next.js Commerce tutorial!

We will continue to extend what you can do with Plasmic for Next.js Commerce, and we’ll also be working toward a more full-featured storefront foundation built upon Next.js Commerce. Please come connect with us on Twitter, tell us about your storefront, and follow us for updates!

Thanks to Lee Robinson for reading drafts of this post!

Follow @plasmicapp on Twitter for the latest updates.