Continuing posts on building a headless e-commerce site with Eleventy and Shopify, it's time to pull in some data from the Shopify API.
The plan is to use the Shopify API to grab products and collections from Shopify and make them available in Eleventy collections so we can build product & collection pages, add data to sitemap.xml, and our RSS feed.
First we need to get our Shopify Data. I'm using a private app to get a token for the API and using GraphQL for the data. You can create a private app in your Shopify account. The GraphiQL app really helpped figure out the queries that I wanted.
For Eleventy, we can drop js
files in the _data
directory to build
custom collections. The exported function should return an array of
data.
I'm calling it colls
so it doesn't interfer w/ the Eleventy collections
global variable. The collection will be named after the file name.
/* src/_data/colls.js */
const fetch = require('node-fetch')
const collectionsQuery = `
{
collections(first: 20) {
edges {
node {
id
handle
title
descriptionHtml
image() {
id
altText
originalSrc
}
products(first: 100) {
edges {
node {
id
title
handle
availableForSale
descriptionHtml
productType
tags
priceRange {
minVariantPrice {
amount
}
maxVariantPrice {
amount
}
}
images(first: 1) {
edges {
node {
altText
originalSrc
transformedSrc(maxWidth: 300, maxHeight:300, crop: CENTER, scale: 1)
}
}
}
}
}
}
}
}
}
}
`
module.exports = function () {
console.log('GETTING COLLECTIONS')
return fetch('https://STORE_NAME.myshopify.com/api/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Shopify-Storefront-Access-Token': 'YOUR_TOKEN_HERE'
},
body: JSON.stringify({ query: collectionsQuery })
})
.then(r => r.json())
.then(data => data.data.collections.edges)
.then(data => data.map((node) => node.node))
}
Just replace the STORE_NAME
and YOUR_TOKEN_HERE
bits.
This will run everytime the site builds. Some caching would be good, but haven't added anything yet.
There is also a products.js
file. The file is the same, except the
GraphQL query.
const productsQuery = `
{
products(first: 100) {
edges {
node {
id
collections(first: 20) {
edges{
node {
id
handle
title
descriptionHtml
}
}
}
title
handle
availableForSale
description
descriptionHtml
productType
tags
vendor
updatedAt
priceRange {
minVariantPrice {
amount
}
maxVariantPrice {
amount
}
}
images(first: 20) {
edges {
node {
altText
originalSrc
transformedSrc(maxWidth: 300, maxHeight:300, crop: CENTER, scale: 1)
}
}
}
variants(first: 30) {
edges {
node {
id
availableForSale
priceV2 {
amount
}
image(maxWidth: 64, maxHeight: 64, crop: CENTER, scale: 1) {
originalSrc
}
sku
title
weight
selectedOptions {
name
value
}
}
}
}
}
}
}
}
`
Now we want to create a page to list all the colls
and each coll
.
The index page was pretty strait forward. Eleventy now has two globals
with our collections, colls
and products
.
This is the Nunjucks template that lists all the categories. Since I don't expect too many it's just going to be one page.
# src/collections/index.njk
---
title: Collections
layout: default
---
<div class="crumbs">
<a href="/">home</a> / collections
</div>
<article class="collections">
<h1>Collections</h1>
{% set startDeck = cycler("<div class=\"row mb-5\">", "", "") %}
{% set endDeck = cycler("", "", "</div>") %}
{% for collection in colls | filterFrontpage %}
{{ startDeck.next() | safe }}
{% include 'collection_link.njk' %}
{{ endDeck.next() | safe }}
{% endfor %}
</article>
The individual pages were harder to figure out. The New Dynamic Slack group came to the rescue again!
Use the Pagination object in Eleventy to create a page per collection by setting the size to 1. Seems obvious now, but was hard to find in the docs.
See the permalink
value has Nunjucks markup? It's the only frontmatter
value that can have markup. Spent a while trying to set the title, but
that doesn't work. More on that below.
Also note the tags
value. By using that Eleventy will add this page to
a collections.collections
value we can use later.
# src/collections/collection.njk
---
title: Collection
layout: default
tags:
- collections
pagination:
data: colls
size: 1
alias: coll
addAllPagesToCollections: true
permalink: collections/{{ coll.handle }}/index.html
---
<div class="crumbs">
<a href="/">home</a> /
{{ coll.title }}
</div>
<article class="collection" data-controller="collection">
<header class="mb-5 pb-5">
<img src="{{ coll.image.originalSrc }}" alt="{{ coll.image.altText }}" height="300" class="w100 img-fluid">
<h1>{{ coll.title }}</h1>
<p>{{ coll.descriptionHtml | safe }}
</header>
{% set startDeck = cycler("<div class=\"row mb-5\">", "", "") %}
{% set endDeck = cycler("", "", "</div>") %}
{% for productNode in coll.products.edges %}
{% set product = productNode.node %}
{% set minPrice = product.priceRange.minVariantPrice.amount %}
{% set maxPrice = product.priceRange.maxVariantPrice.amount %}
{{ startDeck.next() | safe }}
{% include 'product_link.njk' %}
{{ endDeck.next() | safe }}
{% endfor %}
</article>
{% raw %}
Using the data other places in the site, like the layouts, sitemap.xml, or RSS feeds is
easy and available as both our raw API data and the processed page data under the collections
global.
It would have been really nice to just set the frontmatter with data from the paginated collection but it doesn't work that way.
Since I only have these two collections I pull values out of them if they exist.
Here's an example for the title
tag.
{% raw %}
{%- set page_title = (product.title or coll.title or title or "Default title") | escape %}
<title>
{{ page_title }} | {{ site.title | escape }}
</title>
{% endraw %}
Don't think that would work well with a lot of collections. If there were a lot, I'd probably start creating custom filters to move code out of the templates.
Each page gets added to the collections.all
value so the site map was easy. It was already
done!
{% raw %}
# src/sitemap.njk
---
permalink: /sitemap.xml
sitemapIgnore: true
---
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{%- for item in collections.all %}
{%- if item.data.sitemapIgnore !== true %}
<url>
<loc>{{ site.url }}{{ item.url }}</loc>
{% if item.data.premier.date -%}
<lastmod>{{ item.data.premier.date | dateDisplay("toISOString") }}</lastmod>
{%- else -%}
<lastmod>{{ item.date | dateDisplay("toISOString") }}</lastmod>
{%- endif %}
<changefreq>{{ item.data.changefreq | default("monthly", true) }}</changefreq>
<priority>{{ item.data.priority | default("0.5", true) }}</priority>
</url>
{%- endif %}
{%- endfor %}
</urlset>
{% endraw %}
Since we added the tags
value we get each of our pages in auto collections;
collections.collections
and collections.products
. I'm just putting products in
the feed for now. I'm also using the 11ty RSS Plugin for some helper functions and
filters.
The global collections
values are page objects, so to get back to the data
use the data
key followed by the alias
set when paginating. product.data.product
in this case.
# src/feed.njk
{% raw %}
---
permalink: feed.xml
sitemapIgnore: true
metadata:
feed:
subtitle: Skate, Climbing, BJJ gear and apparel.
filename: feed.xml
path: feed/feed.xml
url: https://moshun.us/feed.xml
id: https://moshun.us/
---
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>{{ site.title }}</title>
<subtitle>{{ metadata.feed.subtitle }}</subtitle>
<link href="{{ metadata.feed.url }}" rel="self"/>
<link href="{{ site.url }}"/>
<updated>{{ site.buildTime | dateDisplay }}</updated>
<id>{{ metadata.feed.id }}</id>
<author>
<name>{{ site.author }}</name>
<email>{{ site.email }}</email>
</author>
{%- for product in collections.products %}
{% set absolutePostUrl %}{{ product.url | absoluteUrl(site.url) }}{% endset %}
<entry>
<title>{{ product.title }}</title>
<link href="{{ absolutePostUrl }}"/>
<updated>{{ product.data.product.updatedAt }}</updated>
<id>{{ absolutePostUrl }}</id>
<content type="html">{{ product.templateContent | htmlToAbsoluteUrls(absolutePostUrl) }}</content>
</entry>
{%- endfor %}
</feed>
{% endraw %}
On the server side, we now have dynamic collections and products pulled from an API!
Next time we'll use StimulusJS to create a cart and display products.