Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.uselayers.com/llms.txt

Use this file to discover all available pages before exploring further.

Overview

A complete Layers search implementation in a Shopify theme has four moving pieces:
  1. Autocomplete — typeahead suggestions as the shopper types in the search field.
  2. Prepare — kick off expensive personalization and query understanding before the search results page loads, so it’s ready by the time the page is hydrated.
  3. Execute — fetch ranked, faceted results using the prepared search_id.
  4. Facets and sorts — render filter and sort controls from Liquid metaobjects, then refresh results as the shopper interacts.
The prepare/execute split is the key performance pattern: Layers can start running embeddings, semantic redirects, and personalization the moment the shopper hits enter in the header, so the search results page returns results in a single round trip instead of waiting on full server-side processing.

Prerequisites

  • The Layers Shopify app installed and synced.
  • A storefront access token from shop.metafields.layers.embed_settings. See Liquid integration.
  • The Storefront Pixel installed so deviceId and sessionId are populated automatically. For headless contexts you can pass them manually in the identity body field.
{% comment %} Place once in your theme.liquid layout {% endcomment %}
{% assign embed_settings = shop.metafields.layers.embed_settings.value %}
<script>
  window.layersConfig = {
    storefrontApiToken: '{{ embed_settings.storefrontApiToken }}',
    apiBase: 'https://app.uselayers.com/storefront/v1',
  }
</script>

1. Autocomplete in the header

The Autocomplete endpoint is a GET request that returns suggested queries as the shopper types. It also surfaces semantic redirects — if the typed text matches a configured redirect, the response includes _meta.redirect and your theme should navigate the shopper there directly.
<form action="/search" method="get" role="search">
  <input
    type="search"
    name="q"
    autocomplete="off"
    aria-label="Search"
    id="search-input"
  />
  <ul id="search-suggestions" hidden></ul>
</form>
const input = document.getElementById('search-input')
const list = document.getElementById('search-suggestions')

let abort
input.addEventListener('input', async (event) => {
  const query = event.target.value.trim()
  if (!query) {
    list.hidden = true
    return
  }

  abort?.abort()
  abort = new AbortController()

  const response = await fetch(
    `${window.layersConfig.apiBase}/search/complete?query=${encodeURIComponent(query)}`,
    {
      headers: { 'X-Storefront-Access-Token': window.layersConfig.storefrontApiToken },
      signal: abort.signal,
    }
  ).catch(() => null)

  if (!response?.ok) return
  const data = await response.json()

  // Semantic redirect — navigate directly without a search results page
  if (data._meta?.redirect?.url) {
    window.location.href = data._meta.redirect.url
    return
  }

  list.innerHTML = data.matchedQueries
    .map((s) => `<li><a href="/search?q=${encodeURIComponent(s.query_text)}">${s.query_text}</a></li>`)
    .join('')
  list.hidden = data.matchedQueries.length === 0
})
Debounce input events (150–250 ms) and abort the previous request on each keystroke — the snippet above uses AbortController for that.

2. Prepare the search before the results page

Call prepare the moment the shopper commits to a search (presses enter or clicks a suggestion). The endpoint returns a search_id immediately while Layers continues working on personalization in the background. The prepared data is cached for 15 minutes. You have two practical options: Option A — Prepare on submit, persist the search_id. Best when you can intercept the form submit.
const form = document.querySelector('form[role="search"]')

form.addEventListener('submit', async (event) => {
  event.preventDefault()
  const query = form.q.value.trim()
  if (!query) return

  // Fire and forget — don't await the full response
  const preparePromise = fetch(
    `${window.layersConfig.apiBase}/search/${encodeURIComponent(query)}/prepare`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Storefront-Access-Token': window.layersConfig.storefrontApiToken,
      },
      body: JSON.stringify({
        // Pass any shopper context you already have (cart, geo, marketing)
        context: window.layersContext ?? {},
      }),
    }
  )
    .then((r) => r.json())
    .then((d) => d.search_id)

  // Stash the promise so the results page can await it without re-issuing prepare
  sessionStorage.setItem(`layers:prepare:${query}`, '')
  preparePromise.then((id) => {
    sessionStorage.setItem(`layers:prepare:${query}`, id)
  })

  // Navigate to the results page — prepare keeps running in the background
  window.location.href = `/search?q=${encodeURIComponent(query)}`
})
Option B — Prepare on the search results page itself. Simpler, but loses the in-flight head start during navigation. Use this when you can’t intercept the submit (e.g. native form action).
{% comment %} On templates/search.liquid {% endcomment %}
<script>
  window.layersSearchQuery = {{ search.terms | json }}
</script>
const query = window.layersSearchQuery
const searchIdPromise = fetch(
  `${window.layersConfig.apiBase}/search/${encodeURIComponent(query)}/prepare`,
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Storefront-Access-Token': window.layersConfig.storefrontApiToken,
    },
    body: JSON.stringify({ context: window.layersContext ?? {} }),
  }
)
  .then((r) => r.json())
  .then((d) => d.search_id)
prepare returns HTTP 202 with a search_id even before personalization finishes. Always treat it as non-blocking — never await it on the critical path.
On the results page, call the Search API with the prepared search_id. If prepare hasn’t returned yet, execute waits briefly for it; if more than 15 minutes have passed (or the id is missing), Search falls back to normal processing automatically.
async function executeSearch({ query, sortCode, filters, facetCodes, page = 1 }) {
  const searchId =
    sessionStorage.getItem(`layers:prepare:${query}`) ||
    (await searchIdPromise) // from option B

  const response = await fetch(
    `${window.layersConfig.apiBase}/search/${encodeURIComponent(query)}/execute`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Storefront-Access-Token': window.layersConfig.storefrontApiToken,
      },
      body: JSON.stringify({
        search_id: searchId || undefined,
        sort_order_code: sortCode || undefined,
        filter_group: filters,
        facets: facetCodes,
        retrieveFacetCount: true,
        includeFacetRanges: true,
        pagination: { page, limit: 24 },
      }),
    }
  )

  return response.json()
}
Key body fields:
FieldPurpose
search_idULID returned by prepare. Reuses the cached personalization work. Omit to fall back to standard processing.
sort_order_codeThe selected sort metaobject code. Omit for relevance-ranked results.
filter_groupActive filters expressed via filter expressions.
facetsList of facet codes (or wildcards like options.*) to return values for.
retrieveFacetCountInclude per-value result counts.
includeFacetRangesInclude min/max for numeric facets (price, ratings).

4. Render facet and sort controls from Liquid

Both controls follow the same metaobject pattern documented elsewhere — read the metaobjects in Liquid, then pass codes to the API. Filter sort orders by the search scope on the search page.
{% assign layers_sorts = shop.metaobjects['app--278936322049--sort_order'] | sort: 'order' %}
{% assign layers_facets = shop.metaobjects['app--278936322049--facet'] %}

<section class="search-controls" data-query="{{ search.terms | escape }}">
  <select id="sort-order" name="sort">
    <option value="">Relevance</option>
    {% for sort in layers_sorts %}
      {% assign scopes = sort.scope.value %}
      {% if scopes contains 'search' %}
        <option value="{{ sort.code.value }}">{{ sort.name.value }}</option>
      {% endif %}
    {% endfor %}
  </select>

  <aside class="filters">
    {% for facet in layers_facets %}
      <section data-facet-code="{{ facet.code.value }}">
        <h3>{{ facet.name.value }}</h3>
        <div class="filter-values" data-loading="true"></div>
      </section>
    {% endfor %}
  </aside>
</section>
See the companion guides for the JS that hydrates them: The end-to-end timeline for a typical search:
1

Header submit

Shopper hits enter. Fire prepare (non-blocking) and navigate to /search?q=.... Prepare keeps running while the browser loads the results page.
2

Results page render

Liquid renders the sort dropdown and empty facet groups from metaobjects, including the active sort/filter state from the URL.
3

Execute

Page JS calls execute with the search_id from prepare, plus any active sort and filters from the URL. Render the product grid and hydrate facet values from the response.
4

Interaction

On sort change or filter toggle, re-issue execute with the same search_id plus the updated sort_order_code / filter_group. Update the URL so the state survives refresh and back navigation.

Submitting click feedback

Once a shopper clicks a result, call the Search feedback endpoint with the click event. This is what trains relevancy over time — without it, semantic ranking and personalization can’t improve. The Storefront Pixel handles this automatically for themes using the standard search results template. If you’re rendering your own grid, send the feedback manually using the attributionToken returned in the execute response.

Troubleshooting

execute returns no results for a known-good query. Check that prepare and execute use the same searchQuery path parameter (and URL-encoded identically). A mismatched query invalidates the prepared cache and falls back to standard processing. Suggestions show but redirects don’t fire. The redirect lives at data._meta.redirect.url. Make sure your typeahead handler checks for it before rendering the suggestion list, and that the configured redirect term in the dashboard actually matches the typed query. Stale sort or filter state after back navigation. Read the active state from request.params in Liquid on first render, and mirror every sort/filter change to the URL via history.replaceState. The next page load and any back navigation will then hydrate correctly. Prepare returns 202 but execute is slow. The prepared cache lives for 15 minutes. If execute runs much later, the cache is gone and you’re paying normal processing cost. Don’t cache search_id values across sessions.

See also