Headless Commerce with Craft CMS & Gatsby

(an experiment)

πŸ‘‹πŸ»

I'm Brian Hanson

@brianjhanson

Headless

What should we build?

MVP

  • Product list page
  • Product single page
  • Checkout flow
    • Add / update cart
    • Payment

On the docket

Initial Project Setup

Getting Data

Add / Update Cart

User sessions

Deployment &Β Rebuilding

Initial Project Setup

Craft

Gatsby

client/
β”œβ”€β”€ LICENSE
β”œβ”€β”€ README.md
β”œβ”€β”€ gatsby-browser.js
β”œβ”€β”€ gatsby-config.js
β”œβ”€β”€ gatsby-node.js
β”œβ”€β”€ gatsby-ssr.js
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ package.json
└── src
    β”œβ”€β”€ components
    β”œβ”€β”€ images
    └── pages
server/
β”œβ”€β”€ composer.json
β”œβ”€β”€ composer.lock
β”œβ”€β”€ config
β”œβ”€β”€ craft
β”œβ”€β”€ craft.bat
β”œβ”€β”€ modules
β”œβ”€β”€ plugins
β”œβ”€β”€ storage
β”œβ”€β”€ templates
β”œβ”€β”€ vendor
└── web

Host configuration

Craft

Gatsby

api.headless.test

headless.test

server/config/general.php
<?php
/**
 * General Configuration
 *
 * All of your system's general configuration settings go in here. You can see a
 * list of the available settings in vendor/craftcms/cms/src/config/GeneralConfig.php.
 *
 * @see \craft\config\GeneralConfig
 */

return [
  // Global settings
  '*'          => [
    // Your default settings ...
    
    // Not covered by this POC
    'enableCsrfProtection' => false,

    // Add cookie domain to make life easier
    'defaultCookieDomain' => '.headless.test',

    // Turn on the new headless mode 😎
    'headlessMode' => true
  ],
  
  // Other environment config
];

Getting Data

No GraphQL for Commerce

Element API &
Gatsby Source API Server to the rescue

The Strategy

  • Craft is supplying data via element-api
  • Gatsby is consuming that data via GraphQL thanks to the gatsby-source-apiserver plugin
  • Running a build Gatsby will make a request through its own node server and render the page using that data
server/config/element-api.php
<?php
use craft\commerce\elements\Product;

return [
  'endpoints' => [
    'products.json'                 => static function () {
      return [
        'elementType' => Product::class,
        'criteria'    => [],
        'transformer' => static function ( Product $product ) {
          return [
            'title'          => $product->title,
            'slug'           => $product->slug,
            'id'             => $product->id,
            'defaultVariant' => $product->defaultVariant,
            'defaultPrice'   => $product->defaultPrice,
            'variants'       => $product->getVariants(),
            'dateUpdated'    => $product->dateUpdated
          ];
        },
      ];
    },
  ]
];
client/gatsby-config.js
module.exports = {
  // Additonal config ...
  plugins: [
    // other plugins ...
    {
      resolve: "gatsby-source-apiserver",
      options: {
        // Type prefix of entities from server
        typePrefix: "commerce__",

        // The url, this should be the endpoint you are attempting to pull data from
        url: `${process.env.GATSBY_API_URL}/products.json`,
        method: "get",

        // Name of the data to be downloaded.  Will show in graphQL or be saved to a file
        // using this name. i.e. posts.json
        name: "products",

        // Nested level of entities in response object, example: `data.products`
        entityLevel: `data`,
      },
    },
  ],
}
client/src/pages/index.js
import React from "react";
import { Link, graphql } from "gatsby";

import Layout from "../components/layout";
import SEO from "../components/seo";

const IndexPage = ({ data }) => {
  const products = data.products.nodes;
  return (
    <Layout>
      <SEO title="Home" />
      <div>
        {products.map(product => {
          return (
            <h3 key={product.slug}>
              <Link to={`/${product.slug}`}>{product.title}</Link>
            </h3>
          );
        })}
      </div>
    </Layout>
  );
};

// Here renaming allCommerceProducts to products
// the allCommerceProducts name is equal to
// all<TypePrefix><Name> from our config
export const pageQuery = graphql`...`;
import React from "react";
import { Link, graphql } from "gatsby";

import Layout from "../components/layout";
import SEO from "../components/seo";

const IndexPage = ({ data }) => {...};

// Here renaming allCommerceProducts to products
// the allCommerceProducts name is equal to
// all<TypePrefix><Name> from our config
export const pageQuery = graphql`
  query {
    products: allCommerceProducts(filter: { id: { ne: "dummy" } }) {
      nodes {
        title
        slug
        id: alternative_id
      }
    }
  }
`;
const path = require(`path`)

// https://github.com/gatsbyjs/gatsby/issues/6011
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"

exports.createPages = ({ graphql, actions }) => {
  const { createPage } = actions

  return graphql(
    `
      {
        allCommerceProducts(filter: { id: { ne: "dummy" } }) {
          edges {
            node {
              slug
            }
          }
        }
      }
    `
  )
    .then(result => {...})
    .catch(err => {...})
}
client/gatsby-node.js
const path = require(`path`)

exports.createPages = ({ graphql, actions }) => {
  const { createPage } = actions

  return graphql(...)
    .then(result => {
      if (result.errors) {
        throw result.errors
      }

      // Create blog posts pages.
      const products = result.data.allCommerceProducts.edges

      products.forEach(product => {
        createPage({
          path: product.node.slug,
          component: path.resolve("./src/templates/product.js"),
          context: {
            slug: product.node.slug,
          },
        })
      })

      return null
    })
    .catch(err => {...})
}
client/gatsby-node.js

Add / Update Cart

// templates/product.js
import ...

const ProductTemplate = ({ data, location }) => {
  const product = data.product;
  const price = parseFloat(product.defaultPrice);

  return (
    <Layout location={location}>
      <h1>{product.title}</h1>
      <p>{`$${price.toFixed(2)}`}</p>
    </Layout>
  )
}

export default ProductTemplate

export const pageQuery = graphql`
  query ProductBySlug($slug: String!) {
    product: commerceProducts(slug: { eq: $slug }) {
      title
      slug
      defaultPrice
    }
  }
`
// templates/product.js
import ...

const ProductTemplate = ({ data, location }) => {
  const product = data.product;
  const price = parseFloat(product.defaultPrice);

  return (
    <Layout location={location}>
      <h1>{product.title}</h1>
      <p>{`$${price.toFixed(2)}`}</p>
      <button type="button" onClick={updateCart}>
        Add To Cart
      </button>
    </Layout>
  )
}

export default ProductTemplate

export const pageQuery = graphql`
  query ProductBySlug($slug: String!) {
    product: commerceProducts(slug: { eq: $slug }) {
      title
      slug
      defaultPrice
    }
  }
`
// templates/product.js
import ...

const ProductTemplate = ({data, location}) => {
  const [cart, setCart] = React.useState({})
  const product = data.product
  const price = parseFloat(product.defaultPrice)

  const updateCart = () => {
    axios.post(process.env.GATSBY_API_URL,
      qs.stringify({
        action: "commerce/cart/update-cart",
        qty: 1,
        purchasableId: product.defaultVariant.id,
      }),
      {
        headers: {
          Accept: "application/json",
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    )
    .then(response => {
      const {data: {success, cart} = {}} = response
      if (success) {
        setCart(cart)
      }
    })
    .catch(err => {...})
  }

  return (...)
}

export default ProductTemplate

export const pageQuery = graphql`...`
// templates/product.js
import ...

const ProductTemplate = ({data, location}) => {
  const [cart, setCart] = React.useState({})
  const product = data.product
  const price = parseFloat(product.defaultPrice)

  const updateCart = () => {
    axios.post(process.env.GATSBY_API_URL,
      qs.stringify({
        action: "commerce/cart/update-cart",
        qty: 1,
        purchasableId: product.defaultVariant.id,
      }),
      {
        headers: {
          Accept: "application/json",
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    )
    .then(response => {
      const {data: {success, cart} = {}} = response
      if (success) {
        setCart(cart)
      }
    })
    .catch(err => {...})
  }

  return (...)
}

export default ProductTemplate

export const pageQuery = graphql`...`
// templates/product.js
import ...

const ProductTemplate = ({data, location}) => {
  const [cart, setCart] = React.useState({})
  const product = data.product
  const price = parseFloat(product.defaultPrice)

  const updateCart = () => {
    axios.post(process.env.GATSBY_API_URL,
      qs.stringify({
        action: "commerce/cart/update-cart",
        qty: 1,
        purchasableId: product.defaultVariant.id,
      }),
      {
        headers: {
          Accept: "application/json",
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    )
    .then(response => {
      const {data: {success, cart} = {}} = response
      if (success) {
        setCart(cart)
      }
    })
    .catch(err => {...})
  }

  return (...)
}

export default ProductTemplate

export const pageQuery = graphql`...`
export const pageQuery = graphql`
  query ProductBySlug($slug: String!) {
    product: commerceProducts(slug: { eq: $slug }) {
      title
      slug
      defaultPrice
    }
  }
`
// templates/product.js
import ...

const ProductTemplate = ({data, location}) => {
  const [cart, setCart] = React.useState({})
  const product = data.product
  const price = parseFloat(product.defaultPrice)

  const updateCart = () => {
    axios.post(process.env.GATSBY_API_URL,
      qs.stringify({
        action: "commerce/cart/update-cart",
        qty: 1,
        purchasableId: product.defaultVariant.id,
      }),
      {
        headers: {
          Accept: "application/json",
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    )
    .then(response => {
      const {data: {success, cart} = {}} = response
      if (success) {
        setCart(cart)
      }
    })
    .catch(err => {...})
  }

  return (...)
}

export default ProductTemplate

export const pageQuery = graphql`...`
export const pageQuery = graphql`
  query ProductBySlug($slug: String!) {
    product: commerceProducts(slug: { eq: $slug }) {
      title
      slug
      defaultPrice
      defaultVariant {
        id: alternative_id
      }
    }
  }
`;

πŸ‘Ž

CORS

Header set Access-Control-Allow-Origin "*"

<IfModule mod_rewrite.c>
    RewriteEngine On

    # Send would-be 404 requests to Craft
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_URI} !^/(favicon\.ico|apple-touch-icon.*\.png)$ [NC]
    RewriteRule (.+) index.php?p=$1 [QSA,L]
</IfModule>
server/web/.htaccess

Why tho?

When we make a request to the update-cart action Craft is looking to see if there's an `orderNumber` parameter in the body, if that doesn't exist it falls back to the cart attached to the session. If neither exist, it creates a brand new cart.

Β 

We need to make sure our headless user is getting the proper session

User Sessions

# Not restrictive enough
Header set Access-Control-Allow-Origin "*"

<IfModule mod_rewrite.c>
    RewriteEngine On

    # Send would-be 404 requests to Craft
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_URI} !^/(favicon\.ico|apple-touch-icon.*\.png)$ [NC]
    RewriteRule (.+) index.php?p=$1 [QSA,L]
</IfModule>
server/web/.htaccess
# Origin must be full URL (our Gatsby app is running at 5000)
Header set Access-Control-Allow-Origin "https://headless.test:5000"

# Turn on allow credentials
Header set Access-Control-Allow-Credentials true

# Allow methods
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"

# Make sure Content-Type and Accept headers are allowed
Header set Access-Control-Allow-Headers "Content-Type, Accept"

<IfModule mod_rewrite.c>
    RewriteEngine On

    # Send would-be 404 requests to Craft
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_URI} !^/(favicon\.ico|apple-touch-icon.*\.png)$ [NC]
    RewriteRule (.+) index.php?p=$1 [QSA,L]
</IfModule>
server/web/.htaccess
import React from "react"
import Layout from "../components/layout"
import { graphql } from "gatsby"
import axios from "axios"
import qs from "qs"

const api = axios.create({
  baseURL: process.env.GATSBY_API_URL,
  withCredentials: true
})

const ProductTemplate = ({ data, location }) => {
  const [cart, setCart] = React.useState({})

  const updateCart = () => {
    api
      .post(
        "/",
        qs.stringify({
          action: "commerce/cart/update-cart",
          qty: 1,
          purchasableId: product.defaultVariant.id,
        }),
        {
          headers: {
            Accept: "application/json",
            "Content-Type": "application/x-www-form-urlencoded",
          },
        }
      )
      .then(response => {
        console.log("response", response) // eslint-disable-line
        const { data } = response
        const { success, cart } = data
        if (success) {
          setCart(cart)
        }
      })
      .catch(err => {
        console.log("err", err) // eslint-disable-line
      })
  }

  return (
    <Layout location={location}>
      <h1>{product.title}</h1>
      <h4>Cart</h4>
      <button type="button" onClick={updateCart}>
        Add To Cart
      </button>
      <div style={{ marginTop: 20 }} />
      <pre>{JSON.stringify(cart, null, 2)}</pre>
    </Layout>
  )
}

export default ProductTemplate

export const pageQuery = graphql`
  query ProductBySlug($slug: String!) {
    product: commerceProducts(slug: { eq: $slug }) {
      title
      slug
      id: alternative_id
      defaultVariant {
        id: alternative_id
        stock
      }
      defaultPrice
      variants {
        id: alternative_id
        stock
      }
      dateUpdated {
        date
      }
    }
  }
`
client/templates/product.js

Persisting Cart

Store Order Number in LocalStorage

When requesting cart, check for order number and use it if it exists

Checkout

client/components/CheckoutForm/index.js
const CreditCardForm = () => {
  const { cart } = useCart()

  const handleSubmit = values => { ... }

  return (
    <Formik initialValues={initialValues} onSubmit={handleSubmit}>
      {() => {...}}
    </Formik>
  )
}
client/components/CheckoutForm/index.js
import ...

/**
 * Values is something like
 * {
 *     firstName: 'Testing',
 *     lastName: 'Testorson',
 *     number: '4242424242424242',
 *     expiry: '08/2020',
 *     cvv: '123'
 * }
 */
const handleSubmit = values => {
  // Replace with however you need to get your payment token
  const token = getPaymentToken(args).then(({ token }) => {
    api
      .post({
        token,
        firstName,
      	lastName,
        gatewayId: 1,
        orderNumber: cart.number,
        email: cart.email,
        action: "commerce/payments/pay"
      })
      .then(() => {
        navigate("/order");
      });
  });
};
server/config/element-api.php
<?php
// Other endpoints ...
'order/<number:.+>.json'        => static function ( $number ) {
  return [
    'elementType' => Order::class,
    'criteria'    => [ 'number' => $number ],
    'one'         => true,
    'transformer' => static function ( Order $order ) {
      return [
        'number'    => $order->number,
        'reference' => $order->reference
      ];
    }
  ];
},
client/pages/order.js
import React from "react"
import Layout from "../components/layout"
import { useCart } from "../hooks/useCart"
import { api } from "../utilities/api"

const Order = () => {
  const [order, setOrder] = React.useState({
    number: null,
    reference: null
  })
  const { cart, getCart } = useCart()

  /**
   * When the order page initially renders,
   * use the current cart to get the order
   */
  React.useEffect(() => {
    api
      .get(`order/${cart.number}.json`)
      .then(({ data }) => {
        setOrder(data)
      })
      .catch(err => {
        console.log(err)
      })
  }, [])

  /**
   * When the order.reference changes
   * if it has a value, get a new cart
   */
  React.useEffect(() => {
    if (order.reference) {
      getCart();
    }
  }, [order.reference]);


  return (
    <Layout>
      <h1>Thank you</h1>
      <pre>{JSON.stringify(order, null, 2)}</pre>
    </Layout>
  )
}

export default Order
client/pages/order.js
import React from "react"
import Layout from "../components/layout"
import { useCart } from "../hooks/useCart"
import { api } from "../utilities/api"

const Order = () => {
  const [order, setOrder] = React.useState({
    number: null,
    reference: null
  })
  const { cart, getCart } = useCart()

  /**
   * When the order page initially renders,
   * use the current cart to get the order
   */
  React.useEffect(() => {...}, [])

  /**
   * When the order.reference changes
   * if it has a value, get a new cart
   */
  React.useEffect(() => {
    if (order.reference) {
      getCart();
    }
  }, [order.reference]);


  return (
    <Layout>
      <h1>Thank you</h1>
      <pre>{JSON.stringify(order, null, 2)}</pre>
    </Layout>
  )
}

export default Order

Deployment & Rebuilding

Craft

  • ​Host on Cloudways

Gatsby

  • Host on Netlify

* Need to be at the same domain so they can share cookies

Webhooks Plugin

Reduce Rebuilds

What's Next?

Dynamically set headers

Add more protection

Wrap-up

Separate content from view layer

Static HTML = Faster / Easier to host

Thank you

https://onedesigncompany.com/

http://brianhanson.net/

Resources

Gatsby Docs:
https://www.gatsbyjs.org/docs/

Β 

Gatsby Source API Server:
https://github.com/thinhle-agilityio/gatsby-source-apiserver

Headless Commerce with Craft CMS and Gatsby

By brianjhanson

Headless Commerce with Craft CMS and Gatsby

Headless is all the rage, and doesn't seem to be dying down anytime soon. In my talk, I'll walk through how we built a proof of concept using Craft Commerce and Gatsby. I'll walk through a bit of our decisions, some of the difficulties we faced and how we worked around them from the initial set commit through deployment.

  • 1,645