Collection Pages and Merchandising Controls Intermediate 10 min read

Pagination vs Load More Patterns

Implement traditional pagination and infinite scroll/load more patterns for navigating collection products.

When collections have many products, you need a way to navigate through them. Let’s explore traditional pagination and modern “load more” patterns, with their tradeoffs.

The Paginate Object

Shopify’s paginate tag creates a pagination context:

{%- paginate collection.products by 24 -%}
{# Products for current page #}
{%- for product in collection.products -%}
{{ product.title }}
{%- endfor -%}
{# Pagination data #}
{{ paginate.current_page }} {# 1, 2, 3... #}
{{ paginate.pages }} {# Total pages #}
{{ paginate.items }} {# Total products #}
{{ paginate.page_size }} {# Products per page (24) #}
{{ paginate.current_offset }} {# Starting index #}
{# Navigation #}
{{ paginate.previous.url }} {# Previous page URL #}
{{ paginate.next.url }} {# Next page URL #}
{{ paginate.parts }} {# Array of page links #}
{%- endpaginate -%}

Basic Numbered Pagination

{# snippets/pagination.liquid #}
{%- if paginate.pages > 1 -%}
<nav class="pagination" aria-label="Pagination">
<ul class="pagination__list">
{# Previous #}
<li class="pagination__item">
{%- if paginate.previous -%}
<a
href="{{ paginate.previous.url }}"
class="pagination__link pagination__link--prev"
aria-label="Previous page"
>
{% render 'icon-chevron-left' %}
<span>Previous</span>
</a>
{%- else -%}
<span class="pagination__link pagination__link--prev pagination__link--disabled" aria-disabled="true">
{% render 'icon-chevron-left' %}
<span>Previous</span>
</span>
{%- endif -%}
</li>
{# Page numbers #}
{%- for part in paginate.parts -%}
<li class="pagination__item">
{%- if part.is_link -%}
<a
href="{{ part.url }}"
class="pagination__link pagination__link--number"
>
{{ part.title }}
</a>
{%- elsif part.title == paginate.current_page -%}
<span
class="pagination__link pagination__link--number pagination__link--current"
aria-current="page"
>
{{ part.title }}
</span>
{%- else -%}
{# Ellipsis #}
<span class="pagination__ellipsis">{{ part.title }}</span>
{%- endif -%}
</li>
{%- endfor -%}
{# Next #}
<li class="pagination__item">
{%- if paginate.next -%}
<a
href="{{ paginate.next.url }}"
class="pagination__link pagination__link--next"
aria-label="Next page"
>
<span>Next</span>
{% render 'icon-chevron-right' %}
</a>
{%- else -%}
<span class="pagination__link pagination__link--next pagination__link--disabled" aria-disabled="true">
<span>Next</span>
{% render 'icon-chevron-right' %}
</span>
{%- endif -%}
</li>
</ul>
</nav>
{%- endif -%}

Pagination Styles

.pagination {
display: flex;
justify-content: center;
padding: var(--spacing-xl) 0;
}
.pagination__list {
display: flex;
align-items: center;
gap: var(--spacing-xs);
list-style: none;
padding: 0;
margin: 0;
}
.pagination__link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
padding: 0 var(--spacing-sm);
font-size: 0.875rem;
text-decoration: none;
color: inherit;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
transition: all 0.2s;
}
.pagination__link:hover:not(.pagination__link--disabled):not(.pagination__link--current) {
border-color: var(--color-text);
}
.pagination__link--current {
background: var(--color-text);
color: var(--color-background);
border-color: var(--color-text);
}
.pagination__link--disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pagination__link--prev,
.pagination__link--next {
gap: var(--spacing-xs);
}
.pagination__link svg {
width: 16px;
height: 16px;
}
.pagination__ellipsis {
padding: 0 var(--spacing-xs);
color: var(--color-text-light);
}
/* Mobile: Simpler pagination */
@media (max-width: 639px) {
.pagination__link--number {
display: none;
}
.pagination__link--current {
display: inline-flex;
}
.pagination__ellipsis {
display: none;
}
}

Simple Prev/Next Pagination

For a cleaner look:

{%- if paginate.pages > 1 -%}
<nav class="pagination pagination--simple" aria-label="Pagination">
<div class="pagination__prev">
{%- if paginate.previous -%}
<a href="{{ paginate.previous.url }}" class="pagination__button">
{% render 'icon-arrow-left' %}
Previous
</a>
{%- endif -%}
</div>
<div class="pagination__info">
Page {{ paginate.current_page }} of {{ paginate.pages }}
</div>
<div class="pagination__next">
{%- if paginate.next -%}
<a href="{{ paginate.next.url }}" class="pagination__button">
Next
{% render 'icon-arrow-right' %}
</a>
{%- endif -%}
</div>
</nav>
{%- endif -%}

Load More Button

A button that loads additional products without leaving the page:

{%- paginate collection.products by section.settings.products_per_page -%}
<div class="product-grid-container" data-collection-grid>
<ul class="product-grid">
{%- for product in collection.products -%}
<li class="product-grid__item">
{% render 'product-card', product: product %}
</li>
{%- endfor -%}
</ul>
{%- if paginate.next -%}
<div class="load-more">
<button
class="load-more__button"
data-load-more
data-next-url="{{ paginate.next.url }}"
>
Load More Products
</button>
<p class="load-more__info">
Showing {{ paginate.current_offset | plus: collection.products.size }}
of {{ paginate.items }} products
</p>
</div>
{%- endif -%}
</div>
{%- endpaginate -%}

Load More JavaScript

class LoadMore extends HTMLElement {
connectedCallback() {
this.button = this.querySelector('[data-load-more]');
this.grid = document.querySelector('.product-grid');
this.container = document.querySelector('[data-collection-grid]');
this.button.addEventListener('click', this.loadMore.bind(this));
}
async loadMore() {
const nextUrl = this.button.dataset.nextUrl;
if (!nextUrl) return;
this.button.disabled = true;
this.button.textContent = 'Loading...';
try {
const response = await fetch(nextUrl);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Get new products
const newProducts = doc.querySelectorAll('.product-grid__item');
// Append to existing grid
newProducts.forEach((product) => {
this.grid.appendChild(product);
});
// Update load more button
const newLoadMore = doc.querySelector('[data-load-more]');
if (newLoadMore) {
this.button.dataset.nextUrl = newLoadMore.dataset.nextUrl;
this.button.disabled = false;
this.button.textContent = 'Load More Products';
// Update count
const newInfo = doc.querySelector('.load-more__info');
if (newInfo) {
this.querySelector('.load-more__info').innerHTML = newInfo.innerHTML;
}
} else {
// No more products
this.remove();
}
// Update URL for back button
history.replaceState({}, '', nextUrl);
} catch (error) {
console.error('Load more failed:', error);
this.button.disabled = false;
this.button.textContent = 'Try Again';
}
}
}
customElements.define('load-more', LoadMore);
{# Wrap the load more section #}
<load-more class="load-more">
<button ...>Load More</button>
<p class="load-more__info">...</p>
</load-more>

Infinite Scroll

Automatically load products when scrolling near the bottom:

class InfiniteScroll extends HTMLElement {
connectedCallback() {
this.grid = document.querySelector('.product-grid');
this.loading = false;
this.nextUrl = this.dataset.nextUrl;
if (!this.nextUrl) return;
// Create intersection observer
this.observer = new IntersectionObserver(this.onIntersect.bind(this), { rootMargin: '200px' });
this.observer.observe(this);
}
disconnectedCallback() {
this.observer?.disconnect();
}
async onIntersect(entries) {
if (!entries[0].isIntersecting || this.loading || !this.nextUrl) return;
this.loading = true;
this.showLoader();
try {
const response = await fetch(this.nextUrl);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Get new products
const newProducts = doc.querySelectorAll('.product-grid__item');
newProducts.forEach((product) => this.grid.appendChild(product));
// Get next URL
const nextTrigger = doc.querySelector('infinite-scroll');
this.nextUrl = nextTrigger?.dataset.nextUrl;
if (!this.nextUrl) {
this.showComplete();
this.observer.disconnect();
}
} catch (error) {
console.error('Infinite scroll failed:', error);
this.showError();
} finally {
this.loading = false;
this.hideLoader();
}
}
showLoader() {
this.querySelector('.infinite-scroll__loader').hidden = false;
}
hideLoader() {
this.querySelector('.infinite-scroll__loader').hidden = true;
}
showComplete() {
this.querySelector('.infinite-scroll__complete').hidden = false;
}
showError() {
this.querySelector('.infinite-scroll__error').hidden = false;
}
}
customElements.define('infinite-scroll', InfiniteScroll);
{%- if paginate.next -%}
<infinite-scroll
class="infinite-scroll"
data-next-url="{{ paginate.next.url }}"
>
<div class="infinite-scroll__loader" hidden>
{% render 'loading-spinner' %}
<span>Loading more products...</span>
</div>
<div class="infinite-scroll__complete" hidden>
<p>You've seen all {{ paginate.items }} products</p>
</div>
<div class="infinite-scroll__error" hidden>
<p>Failed to load more products</p>
<button onclick="location.reload()">Refresh</button>
</div>
</infinite-scroll>
{%- endif -%}

Comparing Approaches

FeatureNumbered PaginationLoad MoreInfinite Scroll
SEOExcellent (pages indexed)Good (initial page)Poor
UXFamiliarIntuitiveSeamless
PerformanceLow memoryMediumHigh memory
AccessibilityGoodGoodRequires care
Back buttonWorks naturallyNeeds handlingTricky
ProgressClear (page X of Y)ClearUnclear

Hybrid Approach

Combine pagination with load more for best of both:

{%- if paginate.pages > 1 -%}
<div class="pagination-wrapper">
{# Load more button (JavaScript enhanced) #}
{%- if paginate.next -%}
<load-more class="load-more">
<button class="load-more__button" data-load-more data-next-url="{{ paginate.next.url }}">
Load More Products
</button>
</load-more>
{%- endif -%}
{# Fallback pagination (works without JS) #}
<noscript>
{% render 'pagination', paginate: paginate %}
</noscript>
{# Or show both #}
<details class="pagination-toggle">
<summary>Or browse by page</summary>
{% render 'pagination', paginate: paginate %}
</details>
</div>
{%- endif -%}

Products Per Page Setting

Let merchants configure products per page:

{
"type": "range",
"id": "products_per_page",
"label": "Products per page",
"min": 8,
"max": 48,
"step": 4,
"default": 24
}
{%- paginate collection.products by section.settings.products_per_page -%}

Scroll Position Restoration

When using load more, help users return to their position:

// Save scroll position before navigation
window.addEventListener('beforeunload', () => {
sessionStorage.setItem('scrollPos', window.scrollY);
sessionStorage.setItem('scrollUrl', window.location.href);
});
// Restore on page load
document.addEventListener('DOMContentLoaded', () => {
const savedUrl = sessionStorage.getItem('scrollUrl');
const savedPos = sessionStorage.getItem('scrollPos');
if (savedUrl === window.location.href && savedPos) {
window.scrollTo(0, parseInt(savedPos));
}
sessionStorage.removeItem('scrollPos');
sessionStorage.removeItem('scrollUrl');
});

Practice Exercise

Implement pagination that:

  1. Shows numbered pages on desktop
  2. Shows simple prev/next on mobile
  3. Has a “Load More” button alternative
  4. Shows progress (X of Y products)
  5. Works without JavaScript

Test by:

  • Navigating through pages
  • Using the load more button
  • Disabling JavaScript
  • Using back button after loading more

Key Takeaways

  1. Use paginate tag for all pagination
  2. paginate.parts provides page numbers and ellipsis
  3. Numbered pagination is best for SEO and findability
  4. Load more provides smoother browsing experience
  5. Infinite scroll can hurt accessibility and SEO
  6. Hybrid approaches combine benefits
  7. Always provide fallback for no-JS
  8. Consider memory usage with many loaded products
  9. Handle back button properly with history API

What’s Next?

The final collection lesson covers Empty States and No-Results UX for handling collections with no products.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...