Shade-Level Collection Grid Hydrated from an External Faceting Service
A Shopify collection template that lists shades instead of products: the section renders an empty grid shell, then builds every card, facet and count in the browser from a bespoke faceting service outside the theme.
Shopify's collection grid lists one card per product, so a twelve-shade blush appears once and cannot be filtered by color or finish at the level people actually shop. We built a collection section that renders an empty shell and hydrates shade-level cards, facets, counts, sort and paging from an external faceting service — keeping the theme editor, the cart drawer and the collection URL intact.
Fact Strip
- Client: Nudestix
- Surface: Product listing page
- Templates served: 1 (a dedicated variant collection template)
- Complexity: High
- Attribution: Deploi-authored. The theme section, the card renderer, the filter UI and the state handling are ours. The faceting service the page calls is not. That is client infrastructure; this page claims the storefront-side engineering only.
- Base theme: Dawn 6.0.2
- Status: Live, verified 2026-09-06
- Evidence: Three code artifacts plus four client working records
- Platform primitives: 5
The Problem
A Shopify collection lists products. That's a platform fact, not a theme choice: collection.products iterates products, collection.filters facets products, and a variant has no independent place in the grid. For a color-cosmetics catalog that inverts the thing a shopper is choosing. Nudestix sells shades. A twelve-shade blush appears in the grid exactly once, eleven shades sit behind a swatch on the product page, and no amount of product-level filter work can surface "matte, warm-toned and in stock" as the row of shades that answers it. Merchandisers had the mirror-image problem: no way to lead a collection with one hero shade, and no way to count the category the way it reads to a customer.
The Constraint
There is no first-party way to paginate, sort or facet a variant-level result set in Liquid. Storefront filtering operates on products and exposes product-level metafield filters only; paginate over a metafield list of variant references cannot be filtered server-side, so the list you can page is not the list you can facet. Dawn 6.0.2's FacetFiltersForm assumes the opposite architecture from the one this page needs — a Liquid-rendered product grid re-fetched through the Section Rendering API. And the page could not be built in a vacuum: the site's existing wishlist and quick-add plumbing had to keep working on cards that no longer come out of Liquid at all.
What We Built
The section is main-collection-product-grid-variants.liquid, mounted on a dedicated variant collection template. What it renders server-side is deliberately thin: an empty #product-grid list, a #loadingOverlay, the theme-editor furniture around them, and nothing else. Everything a shopper sees is built in the browser.
On load, the section makes one call to the faceting service with no parameters. That response carries facets, facet_metadata and total_without_filters, and it is what the filter UI is built from — the accordion of facet groups, the live (n) counts, the price bounds, the sort options. Every call after that appends state: per_page, page, sort_by, min_price / max_price, and repeated facet keys including product_type, tags, inventory_available and the metafield-backed keys for finish, benefits, formulation and color.
Two fetch paths share one query-string builder. fetchAndRenderProducts() replaces the grid on page one and on any filter change; loadMoreProducts() appends the next page without clearing what is already there. Cards are template strings: image plus hover product image, a cleaned title, subtext, regular and compare-at price, a /cart/add product form and a per-variant <quick-add-modal>. Sold-out shades render a disabled Sold out button; in-stock shades render add to bag | $<price>. Adding to cart posts to /cart/add.js and then re-renders cart-drawer through ?section_id=, so the theme's own drawer stays the cart.
Around the grid sits an active-filter pill rail, a mobile filter sidebar with body-scroll locking, a price-range mini-form reusing Dawn's filter.v.price.gte and filter.v.price.lte input names, a sort <select>, and a Load More button. Filter and page state are mirrored into the query string with history.pushState and re-read on popstate, so a filtered shade view stays a URL a shopper can send to someone. Color facets render as swatches built from metaobject references the service returns, reading option.reference.fields.label and .color.
Merchandising lives in the section schema rather than in code. A promo_grid block drops a one-wide, two-wide or full-width promotional tile at a numeric grid position for a named collection handle, with its own button, hover and text colors applied at runtime; when a tile is present the per-page count is adjusted so rows stay even. A theme-editor Read More block places body copy above or below the grid. variant-facet.liquid is a stylesheet-only stub — the facet markup it nominally owns is generated in JavaScript, so the snippet exists to enqueue the facet CSS and nothing else.
Why This Way
The commercial requirement — facet and sort a variant-level catalog, with counts — is outside what Liquid or Shopify's product filters can express. So the result set moved to a service that indexes variants, and the theme's job became rendering it. Keeping this as a normal Shopify section rather than a headless page is the part worth defending: the theme editor still controls products per page, columns, promo tiles and read-more copy; the theme's cart drawer is still the cart; the collection URL is still a collection URL. Only the grid body is client-rendered.
Here is what that costs you. This page's product content no longer comes from Liquid, so the grid depends on a service outside the theme, and the shade index is a second source of truth that has to agree with Shopify. Reusing Dawn's facet class names, price-range input names and facet-remove markup carries the existing component CSS and accessibility patterns across unchanged. It doesn't change the fact that this is the one collection surface where view-source no longer shows you the products. If that trade makes you uncomfortable, it should — it made us think about it for a while too.
Why Not an App
Search-and-discovery apps are the usual purchase at this point, and they would have solved the wrong problem. They facet products. A twelve-shade blush would still have collapsed into one card, and per-shade merchandising, per-shade add-to-cart and shade-level counts are the entire argument for this page. A shade grid is a different object from a product grid, not a better-tuned one, and no amount of configuration turns the second into the first. The trade Nudestix accepted is a dependency: the storefront depends on a service outside the theme rather than on a vendor's hosted index, with everything that implies about who is responsible when the shape of the catalog changes and who fixes it when a facet stops returning what a merchandiser expects.
Implementation Notes
- The section renders only a shell.
#product-gridis an empty<ul>and every card is assembled as a template string in the section's inline script — there is no Liquid card partial in this path. cleanProductTitle()strips weight and size suffixes and promotional price text out of API titles before they reach the card, so merchandising noise carried in a product title never renders in the grid.updateFacetCounts()rewrites the(n)count in every facet label in place from thefacetsobject on each response, so counts always describe the current filter combination rather than the unfiltered catalog.inventory_availableis special-cased into explicit In Stock and Out of Stock checkboxes with counts, rather than being rendered as a single boolean facet — availability reads as a choice, not a switch.- Wishlist buttons are re-initialized through the vendor SDK after every grid render, because the cards they attach to are destroyed and rebuilt on each filter change.
- A
MutationObserverpaired with a scroll listener keeps the desktop filter sidebar pinned as the grid grows taller through successive Load More appends, because the sidebar's containing block changes height underneath it. - Promo tiles are scoped by both grid position and collection handle, so one section instance can carry tiles for several collections and a tile configured elsewhere never renders here.
Edge Cases
- Zero variants from the service and zero variants server-side both render the same localized empty state, with a link back to the unfiltered collection.
- A failed fetch renders an inline "Unable to load products. Please try again later." block rather than an empty grid that looks like a sold-out category.
- Price-range inputs validate that min is less than or equal to max, clamp to the maximum the service reports, and block non-numeric keypresses.
- The pagination container hides itself when the result set fits on one page, and Load More removes itself on the last page.
- The unfiltered total is captured only while no filter is active, so the mobile "X of Y products" counter keeps a stable denominator as facets change.
- A compare-at price renders its strikethrough only when it is actually greater than the selling price, so a shade priced at parity never displays a meaningless crossed-out number.
Platform Primitives Used
- Liquid section schema and settings — carries products per page, columns, the promo-tile blocks and the read-more block, so merchandisers configure the grid without a deploy.
- Metafields — the finish, benefits, formulation and color facet keys are metafield-backed. Without that they would not be indexable as facets at all.
- Metaobjects — color swatches are drawn from metaobject references returned with each color facet option, giving a label and a hex value per swatch.
- Cart Ajax API — card-level add-to-cart posts to
/cart/add.js, so a shopper takes a shade from the grid without a product-page round trip. - Section Rendering API — re-renders
cart-drawerby section id after an add, keeping the theme's drawer authoritative over a page it did not render.
Integrations in Play
- Wishlist Hero — coexists with the client-rendered grid; its buttons are re-initialized after each render so wishlist state survives a filter change.
Where It Runs
One surface, one template. The shade-level grid mounts on a dedicated variant collection template on the product listing page, sitting alongside the standard product grid rather than replacing it. Collections that read as a shade range get this template; the rest keep Dawn's grid. That split is a per-collection merchandising decision, not a site-wide switch.
What This Demonstrates
Primary: Filters, faceted navigation and progressive grid loading — the deepest instance of it in the corpus, because here the facet source is external. It is also the anchor build for variant-level collection listing, and for the pattern of fetching an external search service and rendering the response inside a Liquid section.
How We Know
Three code artifacts read directly from the theme — the section, the stylesheet-only facet snippet and the section's stylesheet — running to roughly 2,500 lines of Liquid, JavaScript and CSS. Alongside them sit four client working records: two task-register entries covering the design and development of a shade-level grid view, and two specification documents describing a variant-level filter model driven by variant metafields, which is the faceting model this grid consumes. The specifications do not name the storefront, so we treat the code as the primary record and the documents as corroboration.
Related Builds
- Main collection grid: merchandisable promo tiles, load-more and filter blocklist — the same storefront's standard grid, and the direct comparison for what a Liquid-rendered grid can do.
- Vertical filter rail plus an in-place mobile filter accordion — the same discovery problem solved without leaving Shopify's own filters.
- Collection page: three filter modes, in-grid promo blocks and infinite scroll — a third storefront, filtering entirely server-rendered.
The Buy-vs-Build Question
Filtering is the category where the buy-or-build answer is usually "customize the native facets." This build is the exception that proves the rule: it is what happens when the unit a shopper chooses is not the unit Shopify lists. Our reasoning on where that line sits is in the filters and faceted navigation decision.
Ready to Make Your Catalog Browse the Way People Shop?
If the thing your customers choose isn't the thing your collection page lists, that gap is costing you every session. Contact us today to talk through what your catalog actually needs.