Why Traditional Pagination Hurts Conversion
Numbered pagination on Shopify collection pages forces customers to click through discrete pages, each triggering a full page load that resets scroll position and breaks browsing flow. On desktop this is friction; on mobile it's a conversion killer. The pattern shoppers expect, trained by Instagram feeds, TikTok scrolls, and Amazon's endless product grids, is continuous content loading with zero interruption.
AJAX-based collection pagination replaces numbered page links with dynamic content loading. Products appear as the customer browses, either via an explicit "Load More" button or automatic infinite scroll triggered by scroll position. The first page is server-rendered by Liquid for SEO and fast initial paint. Subsequent pages are fetched asynchronously using Shopify's Section Rendering API and appended to the existing product grid without a full page reload.
Implementation details matter. A naive approach that fetches entire page HTML and extracts product cards works but wastes bandwidth. Poor scroll handling tanks performance on mobile. And if JavaScript fails or search engine crawlers can't see beyond page one, you lose both customers and rankings. This article walks through a production-ready implementation using custom HTML elements, the Section Rendering API, and progressive enhancement, based on a real Shopify theme project.
Two Approaches: Load More vs Infinite Scroll
There are two interaction models for AJAX pagination, and the choice affects UX, accessibility, and implementation complexity.
Load More button, the customer clicks an explicit button to fetch the next batch of products. This gives users control over when content loads, creates a clear visual boundary between batches, and is straightforward to implement. It's also the more accessible pattern, screen readers and keyboard users can interact with a standard button element without ambiguity about what's happening on the page.
Infinite scroll, products load automatically as the customer scrolls toward the bottom of the grid. This feels seamless for browsing-heavy categories like fashion or home decor where customers want to explore without interruption. The tradeoff is that users can feel overwhelmed in large catalogs, and reaching the footer becomes impossible if new content loads endlessly.
The pragmatic choice is a hybrid approach: use an IntersectionObserver to automatically trigger loading for the first 3–4 pages, then switch to a "Load More" button for the remainder. This gives the seamless browsing experience upfront while keeping large catalogs navigable. For this implementation, we start with Load More as the baseline and show how to upgrade to IntersectionObserver later.
Architecture: Section Rendering API + Custom Elements
The architecture separates server-side rendering from client-side interactivity. Shopify's Liquid engine handles the first page of products, fully rendered HTML that search engines can crawl and browsers paint immediately. JavaScript takes over for subsequent pages, using two custom HTML elements that encapsulate all the pagination and cart interaction logic.
Here's how the pieces connect:
- Liquid section template, renders the product grid with paginate tags, outputs
data-has-next-pageanddata-collection-handleattributes for JavaScript to read <custom-featured-collection>, custom element wrapping the product grid. Manages pagination state (current page, has next page), fetches additional pages via the Section Rendering API, and appends new product cards to the DOM<custom-product-card>, custom element wrapping each product card. Handles AJAX add-to-cart via/cart/add.jsand integrates with Dawn's Cart class for drawer and bubble count updates- Section Rendering API, the
collection.enrichedalternate template returns only the section HTML for a specific page, not the entire page layout. URL pattern:/collections/{handle}?page={n}&view=enriched
The critical insight is that the Section Rendering API lets you request a single section's rendered HTML for any page of the collection. Instead of fetching the full page and parsing out the product grid, you get back just the section content, already rendered by Liquid with full access to the collection object, paginate variables, and all product data.
Why custom elements? Custom elements provide lifecycle hooks (connectedCallback), encapsulated state, and automatic initialization when the browser parses the HTML, including elements appended dynamically via AJAX. No framework needed, no initialization scripts, no race conditions between DOM ready and script execution.
Case Study: The NodNod-Reima Implementation
The implementation covered in this article comes from a real Shopify theme project. The NodNod-Reima theme implements a featured collection section with AJAX-powered "Load More" pagination, responsive product grids, and AJAX add-to-cart, all built with vanilla JavaScript custom elements on top of the Dawn theme.
Source code: Full implementation on GitHub, reimabenson/NodNod-Reima. See the pull request for the complete file diff.
The implementation consists of four files working together:
sections/custom-featured-collection.liquid, the section template with product grid and paginationassets/custom-featured-collection.js, the<custom-featured-collection>custom elementassets/custom-product-card.js, the<custom-product-card>custom elementtemplates/collection.enriched.json, the alternate template used by the Section Rendering API
The Custom Collection Element
The <custom-featured-collection> element is the core of the pagination system. It wraps the product grid, tracks the current page number, and handles fetching and appending new products when the customer clicks "Load More."
Key responsibilities:
- Reading pagination state from
data-*attributes set by Liquid - Fetching the next page via the Section Rendering API using the
collection.enrichedalternate template - Parsing the returned HTML and appending product cards to the grid
- Hiding the Load More button when
hasNextPageis false - Managing loading state on the button during fetch operations
class CustomFeaturedCollection extends HTMLElement {
constructor() {
super();
this.currentPage = 1;
this.collectionHandle = this.dataset.collectionHandle;
this.limit = parseInt(this.dataset.limit) || 12;
this.productGrid = this.querySelector('.product-grid');
this.loadMoreBtn = this.querySelector('.load-more-btn');
if (this.loadMoreBtn) {
this.loadMoreBtn.addEventListener('click', () => this.loadMore());
}
}
async loadMore() {
this.currentPage++;
this.loadMoreBtn.classList.add('loading');
try {
const url = `/collections/${this.collectionHandle}?page=${this.currentPage}§ion_id=custom-collection-grid`;
const response = await fetch(url);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newProducts = doc.querySelectorAll('.product-card');
const hasNextPage = doc.querySelector('[data-has-next-page="true"]');
newProducts.forEach(product => {
this.productGrid.appendChild(product);
});
if (!hasNextPage) {
this.loadMoreBtn.style.display = 'none';
}
} catch (error) {
console.error('Failed to load products:', error);
this.currentPage--;
} finally {
this.loadMoreBtn.classList.remove('loading');
}
}
}
customElements.define('custom-featured-collection', CustomFeaturedCollection);
The URL construction is worth examining. The section_id parameter tells Shopify to render only the specified section for that collection page, rather than the full page template. Combined with the page parameter, this gives you server-rendered HTML for exactly the products you need, with all Liquid filters and logic already applied. The DOMParser approach is safer than innerHTML for extracting elements because it creates a sandboxed document without executing any scripts in the parsed HTML.
The Custom Product Card Element
Each product in the grid is wrapped in a <custom-product-card> element that handles AJAX add-to-cart. This is important because products appended via AJAX won't have event listeners from the initial page load, the custom element's constructor runs automatically when the element is added to the DOM, so dynamically loaded products get full interactivity without manual re-initialization.
class CustomProductCard extends HTMLElement {
constructor() {
super();
this.addToCartBtn = this.querySelector('.add-to-cart-btn');
this.variantId = this.dataset.variantId;
if (this.addToCartBtn && this.variantId) {
this.addToCartBtn.addEventListener('click', (e) => {
e.preventDefault();
this.addToCart();
});
}
}
async addToCart() {
this.addToCartBtn.classList.add('loading');
this.addToCartBtn.disabled = true;
try {
const response = await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [{ id: parseInt(this.variantId), quantity: 1 }],
sections: 'cart-icon-bubble,cart-drawer'
})
});
const data = await response.json();
if (data.sections) {
document.querySelectorAll('.cart-count-bubble').forEach(el => {
const section = new DOMParser()
.parseFromString(data.sections['cart-icon-bubble'], 'text/html');
const newBubble = section.querySelector('.cart-count-bubble');
if (newBubble) el.replaceWith(newBubble);
});
}
} catch (error) {
console.error('Add to cart failed:', error);
} finally {
this.addToCartBtn.classList.remove('loading');
this.addToCartBtn.disabled = false;
}
}
}
customElements.define('custom-product-card', CustomProductCard);
The sections parameter in the /cart/add.js request is a Dawn theme convention. It tells Shopify to include rendered HTML for the specified sections in the response, so you can update the cart icon bubble count and cart drawer contents without making additional requests. This single-request pattern, add to cart and get updated UI HTML in one round trip, is significantly faster than the add-then-fetch approach used by many themes.
Dawn Cart class integration. If your theme extends Dawn's cart, you can dispatch a cart:refresh event instead of manually updating the bubble. Dawn's Cart class listens for this event and handles all UI updates including the cart drawer, item count, and accessibility announcements.
The Liquid Section Template
The section template renders the initial product grid and provides the data attributes that JavaScript needs to manage pagination. The paginate tag handles splitting the collection into pages, while data attributes on the wrapper element communicate pagination state to the custom element.
{% assign collection = collections[section.settings.collection] %}
{% assign products_per_row = section.settings.products_per_row | default: 4 %}
<custom-featured-collection
data-collection-handle="{{ collection.handle }}"
data-has-next-page="{{ collection.products.size | divided_by: section.settings.products_per_page | plus: 1 }}"
class="featured-collection"
>
{% if section.settings.heading != blank %}
<h2 class="section-heading">{{ section.settings.heading }}</h2>
{% endif %}
{% paginate collection.products by section.settings.products_per_page %}
<div
class="product-grid grid--{{ products_per_row }}-col"
data-has-next-page="{{ paginate.next.is_link }}"
>
{% for product in collection.products %}
<custom-product-card
class="product-card"
data-variant-id="{{ product.selected_or_first_available_variant.id }}"
data-available="{{ product.available }}"
>
<a href="{{ product.url }}" class="product-card__link">
<div class="product-card__image-wrapper aspect-ratio--{{ section.settings.image_ratio }}">
{% if product.featured_image %}
<img
src="{{ product.featured_image | image_url: width: 600 }}"
alt="{{ product.featured_image.alt | escape }}"
loading="lazy"
width="600"
height="{{ 600 | divided_by: product.featured_image.aspect_ratio | round }}"
/>
{% endif %}
</div>
<div class="product-card__info">
<h3 class="product-card__title">{{ product.title }}</h3>
{% if section.settings.show_variant_title and product.selected_or_first_available_variant.title != 'Default Title' %}
<p class="product-card__variant">{{ product.selected_or_first_available_variant.title }}</p>
{% endif %}
<p class="product-card__price">{{ product.price | money }}</p>
{% if section.settings.show_tags and product.tags.size > 0 %}
<div class="product-card__tags">
{% for tag in product.tags %}
<span class="product-tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</a>
{% if product.available %}
<button class="add-to-cart-btn" type="button">Add to Cart</button>
{% else %}
<button class="add-to-cart-btn sold-out" type="button" disabled>Sold Out</button>
{% endif %}
</custom-product-card>
{% endfor %}
</div>
{% if paginate.next.is_link %}
<div class="load-more-wrapper">
<button class="load-more-btn" type="button">Load More Products</button>
</div>
{% endif %}
<noscript>
<nav class="pagination">
{{ paginate | default_pagination }}
</nav>
</noscript>
{% endpaginate %}
</custom-featured-collection>
{{ 'custom-featured-collection.js' | asset_url | script_tag }}
{{ 'custom-product-card.js' | asset_url | script_tag }}
The data-has-next-page="{{ paginate.next.is_link }}" attribute is the bridge between server-side pagination state and client-side logic. When Liquid renders the page, it evaluates whether there's a next page and outputs true or false. The JavaScript custom element reads this after each AJAX response to decide whether to show or hide the Load More button. The <noscript> block at the bottom provides standard Liquid pagination for users without JavaScript, progressive enhancement in action.
Section Settings Schema
The section schema gives merchants control over the collection display without touching code. These settings appear in the Shopify theme editor and let merchants customize which collection to show, how many products per row, image aspect ratios, and what product metadata to display.
{% schema %}
{
"name": "Custom Featured Collection",
"settings": [
{
"type": "collection",
"id": "collection",
"label": "Collection"
},
{
"type": "text",
"id": "heading",
"label": "Section heading",
"default": "Featured Products"
},
{
"type": "range",
"id": "products_per_row",
"label": "Products per row",
"min": 2,
"max": 6,
"step": 1,
"default": 4
},
{
"type": "range",
"id": "products_per_page",
"label": "Products per page",
"min": 4,
"max": 24,
"step": 4,
"default": 12
},
{
"type": "select",
"id": "image_ratio",
"label": "Image aspect ratio",
"options": [
{ "value": "square", "label": "Square (1:1)" },
{ "value": "portrait", "label": "Portrait (2:3)" },
{ "value": "natural", "label": "Natural" }
],
"default": "natural"
},
{
"type": "checkbox",
"id": "show_tags",
"label": "Show product tags",
"default": false
},
{
"type": "checkbox",
"id": "show_variant_title",
"label": "Show variant title",
"default": true
}
],
"presets": [
{
"name": "Custom Featured Collection"
}
]
}
{% endschema %}
The products_per_row range setting controls the CSS grid column count. In the template, this maps to a grid--{n}-col class that sets grid-template-columns: repeat({n}, 1fr) with responsive breakpoints. The image_ratio select determines the aspect ratio wrapper class, aspect-ratio--square uses padding-bottom: 100%, portrait uses 150%, and natural lets the image dictate its own proportions.
SEO and Progressive Enhancement
The biggest concern with AJAX pagination is search engine visibility. If Google only sees the first page of products, most of your catalog is invisible to organic search. The architecture described here handles this through multiple layers of progressive enhancement.
Server-side rendering, the first page of products is rendered by Liquid, which means the initial HTML response contains fully rendered product cards with titles, images, prices, and URLs. Search engine crawlers see this content without executing JavaScript. This covers your most important products and establishes the collection page as a relevant landing page for category-level keywords.
Pagination link elements, add rel="next" and rel="prev" link tags in the <head> to signal the paginated structure to crawlers:
{% if paginate.previous %}
<link rel="prev" href="{{ paginate.previous.url }}" />
{% endif %}
{% if paginate.next %}
<link rel="next" href="{{ paginate.next.url }}" />
{% endif %}
URL updates with History API, as products load via AJAX, update the browser URL to reflect the current page. If a customer shares the URL or bookmarks it, they return to approximately the same position in the collection:
async loadMore() {
this.currentPage++;
// ... fetch and append products ...
const newUrl = new URL(window.location);
newUrl.searchParams.set('page', this.currentPage);
window.history.replaceState({}, '', newUrl);
}
Noscript fallback, the <noscript> block in the section template renders standard Liquid pagination links. Users with JavaScript disabled see a fully functional paginated collection. When JavaScript is active, the Load More button replaces the pagination links, but the underlying paginated URLs remain crawlable.
Performance Considerations
AJAX pagination shifts the performance challenge from page loads to DOM management. Each batch of products adds nodes to the page, and after several loads, the DOM can grow large enough to affect scroll performance, especially on mobile devices with limited memory.
DOM growth management, for most stores, capping at ~100 products (8–10 pages of 12) is a reasonable limit. After that threshold, replace the Load More button with a "View All Products" link that takes the customer to a filtered or paginated view. This prevents the DOM from growing indefinitely while still providing a generous browsing window.
Lazy loading on appended images, products loaded via AJAX should have the loading="lazy" attribute set on their images. Since Liquid renders the HTML on the server, this attribute is already present in the response. Verify that your theme isn't stripping or overriding lazy loading attributes during DOM insertion.
IntersectionObserver efficiency, if you upgrade to automatic infinite scroll (covered in the next section), there's no need to debounce scroll events. IntersectionObserver runs off the main thread and fires callbacks only when the observed element's intersection status changes. This is inherently more efficient than scroll event listeners that fire on every frame during scrolling.
Network waterfall, each Load More request is a single HTTP request that returns a server-rendered HTML fragment. This is lighter than a full page load, but each product card may contain images that the browser needs to fetch. The loading="lazy" attribute prevents off-screen images from loading immediately, keeping the network manageable even after several loads.
Upgrading to Intersection Observer
Converting the Load More button to true infinite scroll is a small addition to the <custom-featured-collection> element. Instead of waiting for a button click, you observe a sentinel element and trigger loadMore() when it enters the viewport.
connectedCallback() {
this.sentinel = this.querySelector('.load-more-sentinel');
if (this.sentinel) {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !this.isLoading) {
this.loadMore();
}
});
}, { rootMargin: '200px' });
this.observer.observe(this.sentinel);
}
}
disconnectedCallback() {
if (this.observer) {
this.observer.disconnect();
}
}
The rootMargin: '200px' triggers the fetch 200 pixels before the sentinel actually enters the viewport, which gives the request time to complete before the user scrolls to the bottom. This creates the illusion of instant loading, products appear just as the user reaches the end of the current batch.
For the hybrid approach, track how many automatic loads have occurred and disconnect the observer after a threshold:
async loadMore() {
this.autoLoadCount = (this.autoLoadCount || 0) + 1;
// ... fetch and append products ...
if (this.autoLoadCount >= 3) {
this.observer.disconnect();
this.loadMoreBtn.style.display = 'block';
}
}
After three automatic page loads, the observer disconnects and the Load More button reappears. This gives customers the seamless browsing experience for the first ~48 products while preventing runaway DOM growth on larger collections.
Handling Edge Cases
Production-ready AJAX pagination needs to handle several edge cases that are easy to overlook during development but cause real problems for merchants and customers.
Empty collections, if a collection has zero products, the section template should render an empty-state message rather than an empty grid with a Load More button. Check collection.products.size == 0 in Liquid and conditionally render the empty state.
Single-page collections, if the collection has fewer products than the per-page limit, the Load More button should not render at all. The paginate.next.is_link check in the template handles this, it only renders the button when there are additional pages.
Collection filters changing mid-scroll, if your theme supports Shopify's storefront filtering, applying or removing a filter must reset the AJAX pagination state. When a filter changes, reset currentPage to 1, clear the product grid, and re-render from the first page of the filtered results. Listen for the popstate event or intercept filter form submissions to trigger this reset.
Browser back button, if you update the URL with history.replaceState as pages load, the back button should restore the previous URL. Consider using history.pushState instead for each page load, then listen for popstate events to restore the correct scroll position. Store the scroll offset alongside the page number in the state object passed to pushState.
Shopify's 50-page pagination limit, Shopify caps paginate at 50 pages. For a collection with 12 products per page, that's 600 products maximum. If a collection exceeds this, the Load More button will stop producing results after page 50. Handle this gracefully by checking the response for empty product grids and hiding the button when no new products are returned, regardless of the hasNextPage flag.
Key Takeaways
- AJAX pagination using the Section Rendering API is the preferred approach for Liquid-based Shopify themes, it leverages server-rendered HTML without requiring Storefront API access tokens
- Custom HTML elements encapsulate pagination and cart logic in a framework-free, self-initializing pattern that works with dynamically loaded content
- Start with a Load More button for accessibility and explicit user control, then upgrade to
IntersectionObserverfor automatic loading with a hybrid fallback - Server-render the first page with Liquid for SEO crawlability, use
rel="next"/rel="prev"link tags, and update browser URLs with the History API - Include a
<noscript>fallback with standard Liquid pagination for progressive enhancement - Cap DOM growth at ~100 products and ensure
loading="lazy"is set on all appended images for performance - Handle edge cases: empty and single-page collections, filter resets, browser back button, and Shopify's 50-page pagination limit
If your Shopify store needs AJAX-powered collection pagination, infinite scroll, or custom product grid experiences, I can help you architect and build it. Let's talk about what your commerce model actually needs.