How to Build a GraphQL eCommerce App from Scratch

ยท

13 min read

image.png

1.gif

This is what you'll be building!

A survey by Accenture (n>20,000 consumers, across 19 countries) found that 47% of online shoppers would actually consider paying more - if they were provided with an eCommerce experience that exceeded expectations.

Great news if you're an e-retailer, right? But caveat emptor: the exact same percentage also said that they'd avoid purchasing from the retailer if their experience was frustrating, instead.

A snappy, responsive, intuitive experience for your shoppers is critical, so the JAMstack - JavaScript, APIs, Markup - has proven popular for eCommerce. However, that's not all you need.

The key here is to decouple your frontend and your backend, bridging the gap with GraphQL and clever management of content rendering...and that's why WunderGraph - a tool to bring together REST, GraphQL, and all your datasources into a single, secure, typesafe, end-to-end avenue for all of your user experiences - makes perfect sense.

Rock-solid tech, with modern design sensibility sprinkled on top.

So let's take a look at how we can bring some of these JAMstack technologies - Next.js, Strapi, GraphQL, and Snipcart - together in a way that lets you build the exact shopping experience for your users that you want, while making zero compromises on developer experience.

The 30,000 foot view

Here's what you will need for this tutorial:

  1. Node.js & NPM, and Python (required for testing Strapi locally with sqlite3) installed.
  2. An intermediate knowledge of React/Next.js, TypeScript, and CSS (I'm using Tailwind here because I love utility-first CSS; but Tailwind classes translate quite well to pretty much any other styling solution because it's literally just a different way to write vanilla CSS).
  3. A headless CMS to quickly bootstrap our backend (and add GraphQL) without having to deal with bulky third-party clients that we'll have to ship client-side. Strapi is great, so that's the one we're using here.
  4. A Snipcart account to add a cart management system to our eCommerce app (free in test mode). This tutorial uses Snipcart V3.

Secondly, here's a quick diagram of our architecture so you know what you'll be building.

image.png

The Backend - Strapi + GraphQL

While a relational database alone would work for most use-cases, production-ready eCommerce is one where you want something more.

So we'll be using the headless JAMstack CMS, Strapi, as a PIM - a Product Information Management solution - a single source of truth for your eCommerce platform. This will be the hub for your product catalog, where you'd add, modify, enrich, and distribute your product catalog (as GraphQL via an easily-installed plugin) from.

This tutorial uses sqlite3 as the Strapi database, but you can swap that out in production with PostgreSQL - or another one of your choice - easily.

The BFF - WunderGraph

WunderGraph is the key to all of this. It sits in between our frontend and backend, at once serving as a lynchpin for their decoupling, and facilitating typesafe communication.

It is a BFF - Backend-for-Frontend - a service layer, or API gateway, whatever you wish to call it, that serves as the only 'backend' that your frontend can see.

This works by consolidating data from all your datasources (for us here, a GraphQL endpoint) and using GraphQL queries that you write to adapt that data (via secure JSON-RPC) for each separate user experience that you want to provide.

You can use filters, joins, translations, etc, whilst maintaining a clear separation-of-concerns between your Next.js app and your Strapi + DB backend and frees them up to do their own tasks.

The Frontend - Next.js + TailwindCSS + Snipcart

The frontend isn't too complex, really. WunderGraph generates incredibly useful ready-to-use hooks for querying and modifying data (again, based on the GraphQL operations that you write) so for Next.js, you can just use those.

Snipcart is a great way to add an easy to integrate, awesome-looking cart management system to any webapp, and it will serve us well.

This architecture lets you ship your eCommerce experience as a blazing fast, modular, extensible interface rather than the bulky all-in-one client-only apps from days long past.

image.png

image.png

image.png

Here's the finished product, at different screen sizes.

1. Build the Strapi + GraphQL backend

Strapi is an open-source Node.js based headless CMS (Content Management System) that enables programmers to quickly create self-hosted, adaptable, and effective content APIs (RESTful and GraphQL) without writing any code at all.

Step 1: Scaffold a Strapi project

Create a new directory, cd into it, and run the following command:

npx create-strapi-app@latest my-project - quickstart

๐Ÿ’ก The --quickstart flag gives you a SQLite3 database by default. If you prefer to switch databases, see here.

Your Strapi app is now up and running at localhost:1337.

Go ahead and register an admin for the Strapi project. This is entirely local for development and testing. You don't have to worry about valid emails and strong passwords and the like...yet.

Now, you should have access to the Strapi dashboard, at localhost:1337/admin.

Step 2: Create Content Types for your product catalog.

The Strapi admin dashboard lets you quickly build the schema for your data without having to fiddle with the actual relational database. Strapi calls these models Content Types. They're pretty intuitive, all you have to do is create a new Content-Type, and add the fields that your products need.

Go to the Content-Type Builder section on the side bar then select Create new collection type. A modal will appear, where you will enter product as the display name, then click the Continue button.

The next part is busywork. You'll have to add your actual data next (Content Manager -> select a Collection Type -> Create New Entry, rinse and repeat for each product in your catalog) I'm seeding example data from FakeStoreAPI, so according to that, this is what my Content Types look like.

{% image alt="" src="/images/blog/build_graphql_ecommerce/6.png" /%}

Don't forget to add the many-to-one relation between product and category!

Next, you'll need to grant read permissions for find and findOne for the two Collection Types, so that those are made public and can be queried without authentication.

To do this, go to Settings on the side bar, and then :

  • Head to Roles under the Users and Permissions.
  • Click on the Public role to edit it.
  • Click on the Users-permissions dropdown, then check the find and findone options for both product and category.

Now, click the Save button at the top right corner of the screen to update the role.

Now, we can make REST queries like GET /products, and GET /products/:id. But of course, GraphQL would make all of this so much easier. So let's get that done next.

Step 3: Adding a GraphQL API

To convert our (currently RESTful) API endpoints to a single GraphQL one, we have to install the graphql plugin by running the following command in our backend directory:

npm run strapi install graphql

All done, now (re)start the Strapi server using :

npm run develop

...and leave it running for the rest of this tutorial. You now have a GraphQL endpoint at localhost:1337/graphql, and you could play around for a bit here, writing GraphQL queries to explore your data.

2. Set up WunderGraph - and our Frontend.

Now that we have a GraphQL datasource, it's time to set up WunderGraph as our BFF. Thankfully, the WunderGraph devs have provided a starter template we can use to get both WunderGraph and a boilerplate Next.js app up and running at the same time.

Step 1: WunderGraph + Next.js Quickstart

CD into the project root (and out of backend), and type in:

npx -y @wundergraph/wunderctl init - template nextjs-starter -o frontend

Then, CD into the project directory :

cd frontend

Install dependencies, and start :

npm i && npm start

That'll boot up the WunderGraph and Next.js servers (leveraging the npm-run-all package), giving you a splash/intro page at localhost:3000 with an example query (for SpaceX rockets, I believe). If you see that, everything's working.

Now to actually configure WunderGraph to see our Strapi data, and generate a Next.js client for us - complete with querying/mutation hooks that our frontend can then consume and display data from our Strapi product catalog.

Step 2: WunderGraph 101

So the way WunderGraph works is, you tell it which datasources your app depends on, and it consolidates these disparate data sources into a single virtual graph layer, that you can then define data operations (using GraphQL) on. Through powerful introspection, WunderGraph can turn pretty much any data source you can think of into a secure, typesafe JSON-over-RPC API; OpenAPI REST, GraphQL, PlanetScale, Fauna, MongoDB, and more, plus any Postgres/SQLite/MySQL database.

That works out great for us; we already have a working GraphQL datasource!

So let's get right to it. Open wundergraph.config.ts in the .wundergraph directory, and add our Strapi + GraphQL endpoint as a datasource our app depends on, and one that WunderGraph should introspect.

// existing code here

const strapi = introspect.graphql({
  apiNamespace: 'backend',
  url: 'http://localhost:1337/graphql',
})

const myApplication = new Application({
  name: 'app',
  apis: [strapi],
})

// more existing code, leave all of this alone

Notice how every introspected datasource needs a namespace - as every one of them has to get consolidated into a single virtual graph.

Once you've run npm start, WunderGraph monitors necessary files in your project directory automatically, so just hitting save on here will get the code generator running and it'll generate a schema that you can inspect (if you want) - the wundergraph.app.schema.graphql file within /.wundergraph/generated.

Step 3: Defining your Operations using GraphQL

This is the part where we write queries/mutations in GraphQL to operate on WunderGraph's generated virtual graph layer, and get us the data we want.

So go to./wundergraph/operations and create a new GraphQL file. We'll call it AllProducts.graphql. The file/query name does not matter; its contents and namespacing (in the 'namespace_collection' format) do.

So this is our query to get all products.

query AllProducts {
  backend_products {
    data {
      id
      attributes {
        title
        price
        image
        description
        review_score
        review_count
      }
    }
  }
}

Don't celebrate yet; here's another Operation, this time to get one product, by ID (Or slug. Your choice, but slug will probably be better for SEO).

query ProductByID($id: ID!) {
  backend_products(filters: { id: { eq: $id } }) {
    data {
      id
      attributes {
        title
        image
        price
        description
        review_score
        review_count
        category {
          data {
            id
            attributes {
              name
            }
          }
        }
      }
    }
  }
}

Each time you've hit save throughout this process, WunderGraph's code generation has been working in the background (and it will, as long as its server is running), generating typesafe, client-specific data fetching React hooks on-the-fly for you.

You can inspect these in /components/nextjs.ts.

// existing code here

export const useQuery = {
  AllProducts: (args: QueryArgsWithInput<AllProductsInput>) =>
    hooks.useQueryWithInput<AllProductsInput, AllProductsResponseData, Role>(
      WunderGraphContext,
      {
        operationName: 'AllProducts',
        requiresAuthentication: false,
      }
    )(args),
  ProductByID: (args: QueryArgsWithInput<ProductByIDInput>) =>
    hooks.useQueryWithInput<ProductByIDInput, ProductByIDResponseData, Role>(
      WunderGraphContext,
      {
        operationName: 'ProductByID',
        requiresAuthentication: false,
      }
    )(args),
}

// more existing code. Change nothing.

Fine; you can probably celebrate a little now.

As you can tell, WunderGraph has given you two hooks at your disposal, that you can use while building your site - AllProducts, and ProductByID. Both take inputs; the former, a pagination limit, and the latter, well...an ID to filter by, of course.

3. Build the Next.js Frontend

Good news; most of our heavy lifting is done. From now on, we're living entirely in UI land. If you're familiar with Next.js, this should be a breeze. All we'll be doing is building a frontend for our eCommerce app, using those 2 hooks that were exported in the previous step.

import Head from 'next/head'
import '../styles/global.css'

function MyApp({ Component, pageProps }) {
  return (
    <>
      <Head>
        <meta charSet="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <script src="https://cdn.tailwindcss.com"></script>
      </Head>
      <main className="min-h-screen justify-center dark:bg-neutral-900">
        <Component {...pageProps} />
      </main>
    </>
  )
}

export default MyApp

Your _app.tsx File

ย 

import { NextPage } from 'next'
/* WG generated hooks */
import { useQuery, withWunderGraph } from '../components/generated/nextjs'
/* my components */
import NavBar from '../components/NavBar'
import ProductCard from '../components/ProductCard'
import Snipcart from '../components/Snipcart'
/* types */
import { result } from '../types/AllProductsResult'

const Home: NextPage = () => {
  const allProducts = useQuery.AllProducts({
    input: { a: { pageSize: 10 } },
  }).result as result

  return (
    <div>
      <NavBar />
      <Snipcart />
      <div className="relative mx-auto flex flex-col items-center">
        <div className="grid gap-4 p-4 md:grid-cols-2 lg:grid-cols-3">
          {allProducts.data ? (
            <>
              {allProducts.data.backend_products.data.map((product) => {
                return (
                  <ProductCard
                    id={product.id}
                    title={product.attributes.title}
                    image={product.attributes.image}
                    description={product.attributes.description}
                    price={product.attributes.price}
                    rating={product.attributes.review_score}
                    reviews={product.attributes.review_count}
                  />
                )
              })}
            </>
          ) : (
            <>
              <span> Loading...</span>
            </>
          )}
        </div>
      </div>
    </div>
  )
}

export default withWunderGraph(Home)

Your index.tsx File

ย 

/* NextJS stuff */
import Image from 'next/image'
import { useRouter } from 'next/router'
/* my styles */
import styles from './ProductCard.module.css'

type Props = {
  id: string
  title: string
  image: string
  price: number
  description?: string
  rating: number
  reviews: number
}

const ProductCard = (props: Props) => {
  const router = useRouter()
  // handle click on Product Image
  const routeTo = (productId) => {
    router.push({
      pathname: `/products/${productId}`,
    })
  }

  return (
    <div className={styles.container}>
      <div className={styles.card}>
        <div className={styles.imgBx}>
          <Image
            onClick={() => routeTo(props.id)}
            className="img"
            src={props.image}
            alt="Product image"
            height={300}
            width={300}
          />
        </div>
        <div className={styles.contentBx}>
          <div className={styles.title}>
            <h2 onClick={() => routeTo(props.id)}>{props.title}</h2>
          </div>
          <div className={styles.rating}>
            <h2 className="font-medium text-zinc-100">{props.rating}โญ </h2>
            <h4 className="font-medium text-amber-500">{props.reviews} </h4>
          </div>
          <div className={styles.price}>
            <h4 className="text-2xl font-bold text-gray-800">${props.price}</h4>
          </div>
          <div className={styles.cartBtn}>
            {/* <a href="#">Add ๐Ÿ›’</a> */}
            <button
              className="snipcart-add-item"
              data-item-id={props.id}
              data-item-price={'' + props.price}
              data-item-description={props.description}
              data-item-image={props.image}
              data-item-name={props.title}
            >
              Add ๐Ÿ›’
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}

export default ProductCard

Your ProductCard.tsx File

ย 

/* WG generated hooks */
import { useQuery, withWunderGraph } from '../../components/generated/nextjs'
/* NextJS stuff */
import Image from 'next/image'
import { useRouter } from 'next/router'
/* my components */
import NavBar from '../../components/NavBar'
/* types */
import { result } from '../../types/OneProductResult'

type Props = {}

const Product = (props: Props) => {
  const router = useRouter()
  const { id } = router.query
  const productId: string = id as string // needed because id as is will be of type string[] | string (union)

  const oneProduct = useQuery.ProductByID({
    input: { id: productId },
  }).result as result

  const data = oneProduct.data?.backend_products?.data[0].attributes

  return (
    <div>
      <NavBar />
      <div>
        <div className="items-center">
          <div className="grid grid-cols-2 gap-0 p-4">
            <div className="content-center">
              <Image
                src={data?.image}
                alt="Picture of product"
                width={400}
                height={400}
              />
            </div>
            <div>
              <h1 className="text-4xl font-bold text-zinc-100">
                {data?.title}
              </h1>

              <h2 className="mt-2 mb-2 bg-amber-500 p-2 text-2xl font-bold text-black ">
                ${data?.price}
              </h2>
              <h2 className="mt-2 mb-2 text-lg text-neutral-400 ">
                {data?.description}
              </h2>
              <h3 className="mt-2 mb-2 text-xl text-zinc-100 ">
                {data?.review_score} โญ
              </h3>
              <h3 className="mt-2 mb-2 text-lg text-zinc-100 ">
                {data?.review_count} reviews
              </h3>

              <button
                className="snipcart-add-item mt-2 mb-2 bg-zinc-100 p-3 text-xl font-bold text-black"
                data-item-id={oneProduct.data?.backend_products?.data[0].id}
                data-item-price={'' + data?.price}
                data-item-description={data?.description}
                data-item-image={data?.image}
                data-item-name={data?.title}
              >
                Add To Cart ๐Ÿ›’
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
  )
}

export default withWunderGraph(Product)

Your [id].tsx File

4. Implement cart management with Snipcart

Snipcart provides an end-to-end cart management and checkout solution for any webapp, shipping as custom scripts and stylesheets to make integration trivial.

Once you've signed up, double-checked to make sure you're in Test mode, and have your public API key (and stored it in your .env.local file), you're all set.

Just one change: instead of adding Snipcart scripts (get them here) manually to our app's <body> tags, we'll create a component for them and include that in our Index.tsx instead.

/* NextJS stuff */
import Script from 'next/script'

const Snipcart = () => {
  return (
    <>
      <Script
        id="show-cart"
        dangerouslySetInnerHTML={{
          __html: `
          window.SnipcartSettings = {
              publicApiKey: "${process.env.NEXT_PUBLIC_SNIPCART_API_KEY}",
              loadStrategy: "on-user-interaction",
              modalStyle: "side",
          };
          (function(){var c,d;(d=(c=window.SnipcartSettings).version)!=null||(c.version="3.0");var s,S;(S=(s=window.SnipcartSettings).currency)!=null||(s.currency="usd");var l,p;(p=(l=window.SnipcartSettings).timeoutDuration)!=null||(l.timeoutDuration=2750);var w,u;(u=(w=window.SnipcartSettings).domain)!=null||(w.domain="cdn.snipcart.com");var m,g;(g=(m=window.SnipcartSettings).protocol)!=null||(m.protocol="https");var f,v;(v=(f=window.SnipcartSettings).loadCSS)!=null||(f.loadCSS=!0);var E=window.SnipcartSettings.version.includes("v3.0.0-ci")||window.SnipcartSettings.version!="3.0"&&window.SnipcartSettings.version.localeCompare("3.4.0",void 0,{numeric:!0,sensitivity:"base"})===-1,y=["focus","mouseover","touchmove","scroll","keydown"];window.LoadSnipcart=o;document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();function r(){window.SnipcartSettings.loadStrategy?window.SnipcartSettings.loadStrategy==="on-user-interaction"&&(y.forEach(function(t){return document.addEventListener(t,o)}),setTimeout(o,window.SnipcartSettings.timeoutDuration)):o()}var a=!1;function o(){if(a)return;a=!0;let t=document.getElementsByTagName("head")[0],n=document.querySelector("#snipcart"),i=document.querySelector('src[src^="'.concat(window.SnipcartSettings.protocol,"://").concat(window.SnipcartSettings.domain,'"][src$="snipcart.js"]')),e=document.querySelector('link[href^="'.concat(window.SnipcartSettings.protocol,"://").concat(window.SnipcartSettings.domain,'"][href$="snipcart.css"]'));n||(n=document.createElement("div"),n.id="snipcart",n.setAttribute("hidden","true"),document.body.appendChild(n)),$(n),i||(i=document.createElement("script"),i.src="".concat(window.SnipcartSettings.protocol,"://").concat(window.SnipcartSettings.domain,"/themes/v").concat(window.SnipcartSettings.version,"/default/snipcart.js"),i.async=!0,t.appendChild(i)),!e&&window.SnipcartSettings.loadCSS&&(e=document.createElement("link"),e.rel="stylesheet",e.type="text/css",e.href="".concat(window.SnipcartSettings.protocol,"://").concat(window.SnipcartSettings.domain,"/themes/v").concat(window.SnipcartSettings.version,"/default/snipcart.css"),t.prepend(e)),y.forEach(function(h){return document.removeEventListener(h,o)})}function $(t){!E||(t.dataset.apiKey=window.SnipcartSettings.publicApiKey,window.SnipcartSettings.addProductBehavior&&(t.dataset.configAddProductBehavior=window.SnipcartSettings.addProductBehavior),window.SnipcartSettings.modalStyle&&(t.dataset.configModalStyle=window.SnipcartSettings.modalStyle),window.SnipcartSettings.currency&&(t.dataset.currency=window.SnipcartSettings.currency),window.SnipcartSettings.templatesUrl&&(t.dataset.templatesUrl=window.SnipcartSettings.templatesUrl))}})();
        `,
        }}
      />
    </>
  )
}

export default Snipcart

Once that's done, all we have to do is add Snipcart's necessary props to provide cart metadata wherever we have a <button> to add an item to cart.

// Add to Cart button in ProductCard.tsx
<button
  className="snipcart-add-item"
  data-item-id={props.id}
  data-item-price={'' + props.price}
  data-item-description={props.description}
  data-item-image={props.image}
  data-item-name={props.title}
>
  Add ๐Ÿ›’
</button>

All done! Just make sure you've read their docs, as this article is not a comprehensive tutorial for Snipcart's API.

Summing it Up

And that's all, folks! Hopefully, this tutorial has given you an insight into how you can leverage the power of the JAMstack with WunderGraph to go above and beyond what you could do with JAMstack tooling alone.

Meaningfully separating the frontend and the backend - then bridging the gap between them, and doing it in a secure way to boot - is probably the most common pain point when it comes to building webapps. A new paradigm like the JAMstack - while making the web much easier, much faster for developers and users alike, comes with its own set of gotchas and the tedium of writing glue code.

Used with JAMstack and GraphQL as a service layer/BFF, WunderGraph remedies most of these pain points, making sure you concern yourself with only business logic, and building and shipping awesome stuff to your customers. A full end-to-end solution, without any additional dependencies.

ย