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