DevelopersCustomize with code

Customize with code

Relvello's collection and search results can be restyled with CSS, drawn with your own card markup, controlled from JavaScript, or used headless through the storefront API. Everything here works on top of the theme blocks, so Shopify's own results still show if Relvello can't be reached.

Pick a level

Theme editor
Block settings: layout, image corners, title size, badges, hover image, badge and button colors. No code. Block settings
CSS
Stable class names and custom properties. Styling
Card markup
Your own card, from a template in your theme or a JavaScript function. Cards
JavaScript
window.Relvello: methods, events and hooks. JavaScript API
Headless
The storefront API, for theme code that draws everything itself, Hydrogen, Next.js or an app. Storefront API

Styling

Every element Relvello draws has a class starting with rlv-. Those names, and the custom properties below, are a contract: they won't change without notice. Set the properties on .rlv (or :root) in your theme's CSS rather than overriding Relvello's rules.

  • --rlv-columns, --rlv-columns-mobile: grid columns (set by the block's settings).
  • --rlv-gap, --rlv-gutter: space between cards, and the block's side padding.
  • --rlv-ratio: image shape, e.g. 3 / 4.
  • --rlv-radius, --rlv-card-radius: corner rounding for buttons and fields, and for card images.
  • --rlv-title-size: product title size.
  • --rlv-badge-fg, --rlv-badge-bg, --rlv-sale-fg, --rlv-sale-bg: sold-out and sale badge colors.
  • --rlv-button-fg, --rlv-button-bg: primary buttons (default: the section's own colors).
  • --rlv-line, --rlv-soft, --rlv-muted: borders, light fills and secondary text.
.rlv {
  --rlv-card-radius: 0;
  --rlv-sale-bg: #b42318;
  --rlv-title-size: 1rem;
}
.rlv-card__title { text-transform: uppercase; letter-spacing: .04em; }

Your own product cards

Put a <template data-relvello-card> inside the block's section, or one with id="relvello-card" anywhere in the theme for every block. Relvello fills a copy for each product using attributes, so the template is plain HTML (no Liquid conflicts):

  • data-rlv-text="title": sets the element's text.
  • data-rlv-attr="href:url, alt:title": sets attributes, as name:field pairs.
  • data-rlv-if="on_sale" / data-rlv-if="!available": keeps the element only when the field is true (or false).
<template id="relvello-card">
  <a class="card" data-rlv-attr="href:url">
    <img data-rlv-attr="src:image_url, srcset:image_srcset, alt:image_alt" loading="lazy" sizes="25vw">
    <span class="badge" data-rlv-if="on_sale" data-rlv-text="sale_label"></span>
    <h3 data-rlv-text="title"></h3>
    <p><span data-rlv-text="price"></span> <s data-rlv-text="compare_at_price"></s></p>
  </a>
</template>

Fields: id, key, handle, title, vendor, type, url, option (the color or variant on a split card), price (with "From" for a range), price_min, compare_at_price, on_sale, available, sold_out, has_image, image_url, image_srcset, image_alt, image2_url, sale_label, sold_out_label.

Values always go in as text, never as HTML. Event handler attributes (on…), style and srcdoc can't be set, and links and image sources only take http(s) or same-site URLs. Each card is wrapped in <li class="rlv-card rlv-card--custom">, which keeps analytics and "Load more" working.

JavaScript API

window.Relvello is ready when the block's script has run; listen for relvello:ready on document if your code may run first.

const results = Relvello.get('[data-relvello]');   // or Relvello.all()
results.state();            // { mode, handle, query, total, hasMore, products, facets, params, live }
results.setFilters({ 'filter.p.vendor': ['Kestrel'], 'filter.v.price.lte': 80 });
results.setSort('price-ascending');
results.clearFilters();
results.loadMore();
results.refresh();

setFilters takes an object, a query string or URLSearchParams. Only sort_by and filter.* parameters are used; anything else is ignored.

Events

Dispatched on the block and bubbling, so Relvello.on(type, listener) (or a listener on document) hears every block. event.detail holds copies: changing them changes nothing.

rlv:ready
A block started. { mode, handle, query }
rlv:before-load
About to fetch results. { append, params }
rlv:results
Results arrived. { mode, handle, query, products, facets, total, append }
rlv:render
The grid was updated. { total, append }
rlv:filter-change
Sort or filters changed. { params }
rlv:columns
A shopper chose cards per row. { columns, mobile }
rlv:product-click
A card was clicked. { product, position }
rlv:error
Results couldn't load. { message, live } (not live: Shopify's results stay).
Relvello.on('rlv:product-click', (event) => {
  const { product, position } = event.detail;
  dataLayer.push({ event: 'select_item', item_id: product.id, index: position });
});

Hooks

Set them for every block with Relvello.configure (or window.RelvelloConfig before Relvello loads), or for one block with Relvello.get(el).configure.

window.RelvelloConfig = {
  // Change product data before it's drawn (return the product).
  transformProduct: (product) => ({ ...product, title: product.title.replace(/ - .*$/, '') }),
  // Draw a card: return HTML, a DOM node, or null for the default card.
  renderCard: (product, { fields, esc, defaultCard }) =>
    `<a href="${esc(fields.url)}">${esc(fields.title)} · ${esc(fields.price)}</a>`,
};
Escape what you put in HTML. HTML returned by renderCard is inserted as written, so pass every value through esc (or build DOM nodes). Card templates don't need this: they never insert HTML. If a hook throws, that card falls back to Relvello's own.

Placing Relvello from theme code

Turn on Relvello for theme code under Customize › App embeds. It loads Relvello on every page, so any template or section can hold a block; only a grid is required:

<div data-relvello data-handle="{{ collection.handle }}" data-per-page="12">
  <ul class="rlv__grid" data-rlv-grid></ul>
  <button type="button" class="rlv-button" data-rlv-more hidden>Load more</button>
</div>

For search results, use data-mode="search" data-query="". Server-render Shopify's own products in the grid too if you want a fallback for search engines and outages.

Storefront API

A public, read-only JSON API with the same results shoppers see. It needs no key and returns nothing that isn't already public on your storefront. Find your store's endpoint under Organization › API:

GET https://edge.relvello.com/s/<store>/v1/collections/<handle>/products?sort_by&filter.*&limit&cursor
GET https://edge.relvello.com/s/<store>/v1/collections/<handle>/filters
GET https://edge.relvello.com/s/<store>/v1/search?q&sort_by&filter.*&limit&cursor
GET https://edge.relvello.com/s/<store>/v1/search/suggest?q&limit
GET https://edge.relvello.com/s/<store>/v1/recommendations?product&kind&ids&limit

Results carry products, facets, total and next_cursor (pass it as cursor for the next page). A search with a redirect rule carries redirect. Parameters follow Shopify's: sort_by=price-ascending, filter.p.vendor=Kestrel, filter.v.price.gte=20. Errors answer { "ok": false, "error": "…" }.

Products can carry a badge ({ label, style }, from your badges). Collection and search pages carry promos: your promo tiles on that page, each with after, the number of the page's products that come before it. Suggestions carry pages ({ title, url }). recommendations takes kind=bought, similar or viewed (with ids, the shopper's recently viewed product ids, newest first).

With personalization on, collection requests can say who the shopper is: v=n or v=r (new or returning), mk (the Shopify Markets country, like US) and aff (up to three product types they clicked lately, comma-separated). The answer carries segment when one applied.

During an A/B test, collection results follow the shopper's group: pass ab=a or ab=b and the answer carries experiment: { id, arm }. Relvello's blocks send the group they keep in localStorage (rlv_ab, set only with analytics consent); your own code can read and pass the same value. Without ab you get version A.

const api = Relvello.client();                // on your storefront (the page's own store)
// const api = Relvello.client({ store: 'your-store' });   // elsewhere
const page = await api.collection('new-arrivals', { limit: 12, sort_by: 'best-selling' });
const found = await api.search('linen shirt', { 'filter.p.product_type': 'Shirts' });
const typed = await api.suggest('lin');

Which websites may call it

Browsers may call the API only from your store's own domains. To call it from another site (a headless storefront, staging, http://localhost:3000 while developing), add the site under Organization › API › Storefront API and code, or with the API's set_storefront_origins. Use exact origins only: no paths, no wildcards, and plain http only for localhost. Server-side calls (from your own backend) need no entry.

Limits and usage

  • Each call counts as a storefront request toward your plan, like a collection load or a search on the theme.
  • Requests are rate limited per store. Over the limit, the API answers 429; show Shopify's own results or try again shortly.
  • Responses are cached at the edge for about a minute, so changes in the dashboard can take that long to appear.

Security

  • Nothing private is on the page: the embed and blocks only carry the public endpoint, never a key.
  • Changing what the storefront shows (rules, pins, synonyms) needs a signed-in owner or admin, or a read-and-write key for the REST API and MCP server (Organization › API).
  • Card templates insert text only, and query rule redirects only go to http(s) pages.