Skip to content

Example: your own result page

A result page with facets, sorting and paging on top of the Search API. The call is made server side, so the public key never has to leave the backend and the response can be cached.

Backend

php
final class SearchController
{
    private const PER_PAGE = 24;

    public function __construct(private readonly MettyClient $metty) {}

    public function __invoke(Request $request): Response
    {
        $query = SearchQuery::for($request->query->get('q', ''))
            ->perPage(self::PER_PAGE)
            ->page(max(1, $request->query->getInt('page', 1)))
            ->withSections('facets', 'categories', 'suggestions');

        if ($sort = $request->query->get('sort')) {
            $query = $query->sortBy($sort);
        }

        if ($category = $request->query->get('category')) {
            $query = $query->category($category);
        }

        foreach ($request->query->all('filter') as $field => $values) {
            foreach ((array) $values as $value) {
                $query = $query->facet($field, $value);
            }
        }

        try {
            $response = $this->metty->search()->search($query);
        } catch (ConfigurationException) {
            return new RedirectResponse($this->urlFor(['q' => $request->query->get('q', '')]));
        }

        return $this->render('search.html.twig', ['results' => $response]);
    }
}

ConfigurationException is caught because a request beyond the 200 result window is invalid. Returning to the first page is friendlier than showing an error.

Highlighting matches

The server marks matches with square brackets in the highlight field. On the client you only replace them with a tag and compute nothing:

php
function highlight(?string $marked, string $fallback): string
{
    if ($marked === null) {
        return htmlspecialchars($fallback);
    }

    $escaped = htmlspecialchars($marked);

    return str_replace(['[', ']'], ['<mark>', '</mark>'], $escaped);
}
twig
<h3>{{ highlight(product.highlight.name ?? null, product.name)|raw }}</h3>

Escaping has to happen before the brackets are replaced, otherwise HTML from a product name would end up in the page.

Facets and filters

facets is a list of fields, each carrying both a machine name and a display name:

twig
{% for facet in results.facets %}
  <fieldset>
    <legend>{{ facet.label }}</legend>
    {% for value in facet.values %}
      <label>
        <input type="checkbox" name="filter[{{ facet.field }}][]" value="{{ value.value }}"
               {{ value.value in active[facet.field]|default([]) ? 'checked' }}>
        {{ value.value }} <span>({{ value.count }})</span>
      </label>
    {% endfor %}
  </fieldset>
{% endfor %}

Multiple values of one field mean OR, values of different fields are combined with AND. The counts in the response are already recalculated according to the active filters, so they can be displayed as they are.

Paging

php
$lastPage = min(
    $response->pages,
    intdiv(SearchQuery::MAX_WINDOW, $response->perPage),
);

The first 200 results are ranked, so the last available page follows from that boundary rather than from the total number of products found. If you need to offer more, narrow the selection with a category or a facet — deeper paging would return an unordered list anyway.

Calling directly from the browser

The public key is meant for reading, so the Search API can also be called from a frontend:

js
const params = new URLSearchParams({
    key: PUBLIC_API_KEY,
    q: query,
    per_page: '24',
    include: 'facets,categories'
})

for (const [field, values] of Object.entries(filters)) {
    for (const value of values) {
        params.append(`${field}[]`, value)
    }
}

const response = await fetch(`https://search.api.metty.eu/search?${params}`)

if (!response.ok) {
    const { error } = await response.json()
    throw new Error(error)
}

const { products, total, facets } = await response.json()

Send repeated values with brackets (farba[]=modrá&farba[]=čierna). Without them the server keeps only the last value.

If you call search on every keystroke, use autocomplete instead — it has a higher rate limit and a smaller response.

Metty documentation