How can I push sold-out products to the end of a collection using Shopify Liquid or JS (with pagination)?

I have 50+ collections, each with 400–1000 products, and infinite pagination loading 32 products per page.

I want to automatically move sold-out products to the end of the collection and not just the end of each page.

Most Liquid-based solutions only work when the product count is under 250 (no pagination), since Shopify paginates before rendering, so collection.products only represents one page at a time.

Is there a way to push sold-out products to the bottom of the entire collection without manually reordering or using external apps?

Hi, 2 solutions:

  1. Use police stock app from shopify markets
  2. Custom code using api to reorder products from a collection, but limitation is if the collection is auto generated by shopify (smart collection) can’t reorder products.

Hi @chirag_23 ,
No, unfortunately there’s no pure-Liquid solution to push sold-out products to the end of the entire collection when using infinite scroll or pagination — because, as you said, collection.products only includes the current page’s products. Shopify’s pagination happens before Liquid renders, so Liquid can’t reorder the full list on the fly.

Here are some way to do this:

Manual sorting: Create a manual collection (or custom sort order) where you drag sold-out items to the bottom — works but not automatic.

Use a third-party app: Apps like Automated Collections Manager or Push Down Sold Out Products do this automatically by adjusting product positions using the Admin API.

Hey @chirag_23 in short, yes.

This may be a bit of an older technique but I used to do a some fetching of large amounts of content when there was no layout used.

For example, you could utilize Liquid to paginate through at least up to 250 products and then loop through them to capture the available ones and again to capture the unavailable ones. You would fetch the content and then actually paginate & inject client-side. You could either have the JS directly inject product cards from the fetched templates, or by using the product handles and the Section Rendering API to individually fetch product cards of each product before they should be injected. I would recommend whichever approach has better performance.

You may be able to utilize storefront filtering or tag filtering within that approach too or – and I know this is not what you are looking for – you could probably utilize storefront filtering to have “availability” be filterable, they just would not be displayed together at that point. I also believe the pagination limits have recently increased so if you need to work with more than just 1K items you should be able to.

Anyway, yesterday evening I went ahead and started creating a working demo to demonstrate the ability to load in more than 250 products where you only display the unavailable ones at the end.

The following code is just an example using the Dawn theme as a base and probably not the most performant, but it should provide the basis for what you were describing that you were wanting.

Note, it is not a pure-Liquid approach because, as you mentioned with the 250 limit I believe that is currently not doable.

templates/collection.available.liquid
{%- liquid
  layout none

  capture collection_products
    paginate collection.products by 250
      assign available_products = collection.products | where: 'available'

      for product in available_products
        render 'product-card', product: product
        echo ',,,'
      endfor
    endpaginate
  endcapture

  echo collection_products | remove_last: ',,,'
-%}
templates/collection.cards.liquid
{{ 'template-collection.css' | asset_url | stylesheet_tag }}
{{ 'component-card.css' | asset_url | stylesheet_tag }}
{{ 'component-price.css' | asset_url | stylesheet_tag }}

<script src="{{ 'collection-cards.js' | asset_url }}" defer="defer"></script>

<div
  class="product-grid-container"
  id="ProductGridContainer"
>
  <div class="collection page-width">
    <collection-cards
      data-collection-url="{{ collection.url }}"
      data-products-views="available,unavailable"
      data-products-per-page="32"
      data-products-count="{{ collection.products_count }}"
      data-needle-to-split-by=",,,"
      id="product-grid"
      class="
        grid product-grid grid--2-col-tablet-down
        grid--3-col-desktop
      "
    ></collection-cards>
    <div>Loading more items…</div>
  </div>
</div>
templates/collection.unavailable.liquid
{%- liquid
  layout none

  capture collection_products
    paginate collection.products by 250
      for product in collection.products
        unless product.available
          render 'product-card', product: product
          echo ',,,'
        endunless
      endfor
    endpaginate
  endcapture

  echo collection_products | remove_last: ',,,'
-%}
snippets/product-card.liquid
<div class="grid__item">
  {%- render 'card-product',
    card_product: product,
    media_aspect_ratio: 'adapt',
    image_shape: 'default',
    show_secondary_image: false,
    show_vendor: false,
    show_rating: false,
    lazy_load: true,
    skip_styles: false,
    quick_add: false,
    section_id: section.id
  -%}
</div>
assets/collection-cards.js
if (!customElements.get('collection-cards')) {
  customElements.define('collection-cards', class CollectionCards extends HTMLElement { 
    constructor() {
      super();
    }

    connectedCallback() {
      this.allProducts = [];
      this.collectionURL = this.dataset.collectionUrl;
      this.currentPage = -1;
      this.isLoading = false;
      this.loadingElement = this.nextElementSibling;
      this.loadingElementBounds = {};
      this.needle = this.dataset.needleToSplitBy;
      this.onScrollHandler = this.scrollHandler.bind(this);
      this.observer;
      this.pagedProducts = [];
      this.productsPerPage = Number(this.dataset.productsPerPage);
      this.productsPerRequest = 250;
      this.productsViews = this.dataset.productsViews.split(',');
      this.totalProducts = Number(this.dataset.productsCount);

      window.performance.mark('theme:collection_loaded.start');

      this.initCollection();
    }

    disconnectedCallback() {
      window.removeEventListener('scroll', this.onScrollHandler);
    }

    chunkArray(array, chunkSize) {
      const numberOfChunks = Math.ceil(array.length / chunkSize);

      return Array.from({ length: numberOfChunks }, (_, index) => {
        const start = index * chunkSize;
        const end = start + chunkSize;

        return array.slice(start, end);
      });
    }

    async getPage() {
      try {
        if (this.currentPage < this.pagedProducts.length) {
          const products = Array.from(this.pagedProducts[this.currentPage]);

          this.insertAdjacentHTML('beforeend', products.join(''));
        }
      } catch (error) {
        console.error(error);
      }
    }

    async initCollection() {
      try {
        const pageCount = Math.ceil(this.totalProducts / this.productsPerRequest);

        for (let i = 1; i <= pageCount; i += 1) {
          const response = await fetch(`${window.Shopify.routes.root}${this.collectionURL.replace(/^\//, '')}?view=${this.productsViews[0]}&page=${i}`);
          const data = await response.text();

          this.allProducts = this.allProducts.concat(data.split(this.needle));
        }

        if (this.allProducts.length < this.totalProducts) {
          for (let i = 1; i <= pageCount; i += 1) {
            const response = await fetch(`${window.Shopify.routes.root}${this.collectionURL.replace(/^\//, '')}?view=${this.productsViews[1]}&page=${i}`);
            const data = await response.text();

            this.allProducts = this.allProducts.concat(data.split(this.needle));
          }
        }

        this.pagedProducts = this.chunkArray(this.allProducts, this.productsPerPage); // Split into array of pages of products.

        await this.loadNextPage();
  
        if (window.location.search.includes('page=')) {
          const urlParams = new URLSearchParams(window.location.search);
          const page = urlParams.get('page');
    
          if (page) {
            let tempPage = Number(page) - 1;
    
            if (tempPage < 0) {
              tempPage = 0;
            }
    
            if (tempPage > 0) {
              for (let i = 0; i <= tempPage; i += 1) {
                this.currentPage = i;
    
                await this.loadNextPage();
              }
            }
          }
        }

        if (!!(this.loadingElement)) {
          this.loadingElement.textContent = 'Waiting for intersection…';

          this.observer = new IntersectionObserver((entries) => {
            entries.forEach((entry) => {
              this.loadingElementBounds = entry.intersectionRect;

              if (entry.isIntersecting && !this.isLoading) {
                this.loadNextPage();
              }
            });
          }, {
            rootMargin: '0px',
            threshold: 0.1
          });

          this.observer.observe(this.loadingElement);

          window.addEventListener('scroll', this.onScrollHandler, false);

          window.requestIdleCallback(() => {
            if (!!(this.loadingElement) && this.isPartiallyInViewport(this.loadingElement)) {
              this.loadNextPage.bind(this);
            }
          })
        }
      } catch (error) {
        console.error(error);
      }

      performance.mark('theme:collection_loaded.end');
      performance.measure('theme:collection_loaded', 'theme:collection_loaded.start', 'theme:collection_loaded.end');
    }

    isPartiallyInViewport(element) {
      if (!!(element)) {
        const rect = element.getBoundingClientRect();
        const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
        const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
        const isVerticallyVisible = (rect.top < viewportHeight && rect.bottom > 0);
        const isHorizontallyVisible = (rect.left < viewportWidth && rect.right > 0);
      
        return isVerticallyVisible && isHorizontallyVisible;
      }

      return false;
    }

    async loadNextPage() {
      if (this.isLoading) {
        return;
      }

      if (this.currentPage >= this.pagedProducts.length && !!(this.loadingElement)) {
        this.loadingElement.textContent = 'No more items to load.';
        this.observer.unobserve(this.loadingElement);
        return;
      }

      this.isLoading = true;

      if (!!(this.loadingElement)) {
        this.loadingElement.textContent = 'Loading more items…';
      }

      this.currentPage += 1;

      await this.getPage();

      if (!!(this.loadingElement) && this.currentPage < this.pagedProducts.length) {
        this.loadingElement.textContent = 'Waiting for intersection…';
      }

      if (!!(this.currentPage >= this.pagedProducts.length) && !!(this.loadingElement)) {
        this.loadingElement.textContent = 'No more items to load.';
        this.observer.unobserve(this.loadingElement);
      }

      this.isLoading = false;
    }

    onScrollIdle() {
      if (!!(this.loadingElement) && this.isPartiallyInViewport(this.loadingElement)) {
        this.isLoading = false;
        this.loadNextPage();
      }
    }

    resetIdleTimer() {
      clearTimeout(this.idleTimer);
      this.idleTimer = setTimeout(this.onScrollIdle.bind(this), 100);
    }

    scrollHandler() {
      if (!!(this.loadingElement) && this.isPartiallyInViewport(this.loadingElement)) {
        window.requestAnimationFrame(this.loadNextPage.bind(this));
      }

      this.resetIdleTimer();
    }
  });
}

I believe you could adjust the approach to utilize JSON templates without a layout and associated sections instead but for the purposes of this demo I just threw things into Liquid templates. This example also does not mess with the history but it should work if provided a specific actual page within the URL of the collection up-front (without scrolling that “page” into view). I believe I tested it with a new collection that contained 288 active products, and it seemed to work flawlessly as-is.

There is room for improvement, but it is a relatively basic demo to prove what you are wanting to do should be doable without manually reordering or using external apps.