SEO friendly infinite scroll for Shopify themes
Tags: webdev, shopify, seo, liquid
Published: 2022-12-17 14:32:47

Infinite Scroll is an obvious feature to expect on modern E-Commerce websites. Sadly, Shopify doesn’t provide a first class solution for it.

Here is my take on how to implement it in a way that improves the user experience without blocking access to web crawlers and people who doesn’t have javascript enabled.

---

Start by creating section file called `main-collection.liquid`. This is section includes a grid of product, a loading spinner and pagination buttons that will be used by web crawlers. Once we load the script, javascript will take over and those buttons won't be necessary.

```liquid
{% assign page_size = section.settings.page_size %}

{% paginate collection.products by page_size %}
    <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
            {% for product in collection.products %}
                {% render 'product-card', product: product %}
            {% endfor %}
    </div>
    <div class="main-collection-pagination flex m-5 uppercase text-xs">
        {% if paginate.previous.is_link %}
            <div class="flex-1 text-left">
                <a class="main-collection-prev-button" href="{{ paginate.previous.url }}">
                    {{ paginate.previous.title }}
                </a>
            </div>
        {% endif %}
        {% if paginate.next.is_link %}
            <div class="flex-1 text-right">
                <a class="main-collection-next-button" href="{{ paginate.next.url }}">
                    {{ paginate.next.title }}
                </a>
            </div>
        {% endif %}
    </div>
    <div class="main-collection-loading opacity-0 flex justify-center mx-5 my-10 uppercase text-xs">
        <span>Loading...</span>
    </div>
{% endpaginate %}

{% schema %}
{
    "name": "Main Collection",
    "class": "main-collection",
    "settings": [
        {
          "type": "range",
          "id": "page_size",
          "label": "Page size",
          "min": 4,
          "max": 16,
          "step": 1,
          "default": 8,
          "info" : "Number of products displayed per page"
        }
    ],
    "presets": [
        {
            "name": "Main Collection"
        }
  ]
}
{% endschema %}
```

Now let's create an entrypoint for the script that will run when the entire document has been loaded by the browser.

```javascript
import { initMainCollection } from "./main-collection";

async function main() {
  const { templateName } = document.body.dataset;

  if (templateName === "collection") {
    const sectionElement = document.querySelector(
      ".shopify-section.main-collection"
    );
    if (sectionElement instanceof HTMLElement) {
      initMainCollection(sectionElement);
    }
  }
}

document.addEventListener("DOMContentLoaded", main);
```

In the main script we will check if the page contains the `main-collection` section, and if that's the case we will initialize it using a separate function called `initMainCollection`.

The code for this function is pretty long, but this is the idea behind it.

1. Hide pagination buttons
2. Get url of next page results from the next button
3. Wait for when user has scrolled to the bottom
4. If there is another page, fetch the main section html and insert it after the current one.
5. Repeat 1-4 on the new section 

```javascript
/** @param {HTMLElement} section */
export function initMainCollection(section) {
  if (window.IntersectionObserver) {
    setupInfiniteScroll(section);
  }
}

/** @param {HTMLElement} section */
function setupInfiniteScroll(section) {
  const nextButton = section.querySelector(".main-collection-next-button");
  const pagination = section.querySelector(".main-collection-pagination");
  const loading = section.querySelector(".main-collection-loading");

  // When infinite scroll is active, pagination
  // buttons should stay hidden
  if (pagination instanceof HTMLElement) {
    hideElement(pagination);
  }
  if (loading instanceof HTMLElement) {
    loading.style.opacity = "1.0";
  } else {
    // Loading element is integral to the infinite scroll
    // because it will be used as intersection target
    return;
  }

  // Grab url of next page from the button href
  let nextPageUrl;
  if (nextButton instanceof HTMLAnchorElement) {
    nextPageUrl = nextButton.href;
  }

  // If there is no next page link, there is no work to be done.
  if (!nextPageUrl) {
    hideElement(loading);
    return;
  }

  const observer = new IntersectionObserver(function(entries, observer) {
    const entry = entries[0];
    if (entry.isIntersecting) {
      // Unsubscribe right away
      observer.unobserve(entry.target);
      // do something
      renderNextSection();
    }
  });

  observer.observe(loading);

  async function renderNextSection() {
    // Fetch main-collection section from next page
    let nextPageSectionHtml;
    try {
      const response = await fetch(nextPageUrl + "&sections=main-collection");
      const data = await response.json();
      nextPageSectionHtml = data["main-collection"];
    } catch (err) {
      console.error(err);
    }
    // If server returns nothing, leave.
    if (!nextPageSectionHtml) return;

    if (loading instanceof HTMLElement) {
      hideElement(loading);
    }

    // Insert section HTML after the current section
    section.insertAdjacentHTML("afterend", nextPageSectionHtml);
    const nextPageSection = section.nextElementSibling;

    if (nextPageSection instanceof HTMLElement) {
      // Setup infinite scroll on the next section
      setupInfiniteScroll(nextPageSection);
    }
  }
}

/** @param {HTMLElement} element */
function hideElement(element) {
  element.style.display = "none";
}
```