> ## 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.

# Implementing search in Liquid

> Build a Shopify theme search experience with Layers in Liquid using the prepare/execute flow, autocomplete, faceted filtering, and sort metaobjects.

## 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](/developers/liquid-integration).
* The [Storefront Pixel](/shopify-integration/storefront-pixel) installed so `deviceId` and `sessionId` are populated automatically. For headless contexts you can pass them manually in the `identity` body field.

```liquid theme={null}
{% 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](/api-reference/predict) is a `GET` request that returns suggested queries as the shopper types. It also surfaces [semantic redirects](/platform/semantic-redirects) — if the typed text matches a configured redirect, the response includes `_meta.redirect` and your theme should navigate the shopper there directly.

```html theme={null}
<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>
```

```js theme={null}
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((q) => `<li><a href="/search?q=${encodeURIComponent(q)}">${q}</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.

```js theme={null}
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).

```liquid theme={null}
{% comment %} On templates/search.liquid {% endcomment %}
<script>
  window.layersSearchQuery = {{ search.terms | json }}
</script>
```

```js theme={null}
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)
```

<Tip>
  `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.
</Tip>

## 3. Execute the search

On the results page, call the [Search API](/api-reference/search) 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.

```js theme={null}
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:

| Field                | Purpose                                                                                                       |
| :------------------- | :------------------------------------------------------------------------------------------------------------ |
| `search_id`          | ULID returned by `prepare`. Reuses the cached personalization work. Omit to fall back to standard processing. |
| `sort_order_code`    | The selected sort metaobject `code`. Omit for relevance-ranked results.                                       |
| `filter_group`       | Active filters expressed via [filter expressions](/sdk/api-reference/filtering).                              |
| `facets`             | List of facet codes (or wildcards like `options.*`) to return values for.                                     |
| `retrieveFacetCount` | Include per-value result counts.                                                                              |
| `includeFacetRanges` | Include `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.

```liquid theme={null}
{% 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:

* [Rendering facets in Liquid](/developers/rendering-facets-in-liquid) — populating filter values with counts and ranges
* [Rendering sort orders in Liquid](/developers/rendering-sort-orders-in-liquid) — sort dropdown wiring

## Recommended request flow

The end-to-end timeline for a typical search:

<Steps>
  <Step title="Header submit">
    Shopper hits enter. Fire `prepare` (non-blocking) and navigate to `/search?q=...`. Prepare keeps running while the browser loads the results page.
  </Step>

  <Step title="Results page render">
    Liquid renders the sort dropdown and empty facet groups from metaobjects, including the active sort/filter state from the URL.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## Submitting click feedback

Once a shopper clicks a result, call the [Search feedback endpoint](/api-reference/search-feedback) 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

* [Liquid integration](/developers/liquid-integration) — metaobject and metafield access
* [Rendering facets in Liquid](/developers/rendering-facets-in-liquid) — filter sidebar pattern
* [Rendering sort orders in Liquid](/developers/rendering-sort-orders-in-liquid) — sort dropdown pattern
* [Search API](/api-reference/search) and [Prepare Search](/api-reference/search-prepare) endpoints
* [Autocomplete API](/api-reference/predict) — typeahead suggestions
* [Filter expressions](/sdk/api-reference/filtering) — filter group grammar
