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.

Getting the data

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
                }
              }
            }
          }
        }
      }
    }
  }
`

Display the data

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 %}

Use the data anywhere

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.

Layouts

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>

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.

sitemap.xml

Each page gets added to the collections.all value so the site map was easy. It was already done!


# 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>

feed.xml

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

---
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>

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.