Implement search results pages with facets

Elevate, May 20-22-2025, Miami Beach, Florida

Learn how to build a dynamic search results page with full-text search, sorting, and faceted navigation for Zen Electron using the Composable Commerce Product Search API.

After completing this page, you should be able to:

  • Analyze business requirements for a product search results page (including full-text search, sorting, facets, store-specific data, and pricing) and map these requirements to the appropriate Product Search API features and query structures.
  • Construct Product Search API queries that implement core search functionalities, including full-text search across multiple fields, various sorting options (by relevance, date, price), facet definitions (for brand, price, category), and the use of productProjectionParameters for store-specific data and localized pricing.
  • Implement dynamic faceted filtering by applying AND/OR logic within the postFilter to refine search results based on user selections, and describe the process of integrating API query parameters with frontend URL parameters for a shareable and bookmarkable user experience.

Zen Electron aims to provide a powerful and intuitive search experience for its customers. Their key business requirements for the search results page are:

  • Full-text Search: Customers should be able to search across product name and description to find relevant items.
  • Comprehensive Sorting: Results should be sortable by:
    • Relevance (score)
    • Newest products (createdAt descending)
    • Oldest products (createdAt ascending)
    • Highest Price (variants.prices.centAmount descending)
    • Lowest Price (variants.prices.centAmount ascending)
  • Faceted Navigation: Customers need to refine search results using facets for:
    • Brand (for example, "Sony", "Samsung")
    • Price (for example, "$0-$50", "$51-$100")
    • Category (for example, "Laptops", "Smartphones")
  • Store-Specific Results: As Zen Electron operates with multiple Stores, search results must be filtered to show only products available in the customer's current store.
  • Tailored Product Data & Pricing:
    • Product information (like names, descriptions) should be localized based on the store's configured locales.
    • Prices displayed must be the best-matching prices for the customer's context (currency, country, customer group, channel), derived from the store's settings.
    • The API response should return reduced product fields (Product Projections) relevant to the store's locale and pricing context to optimize performance.
  • URL Parameter Integration: The search state (query, sort, filters) should be reflectable in and controllable via URL parameters for shareability and bookmarking.

Mapping requirements to Product Search API queries

To build Zen Electron's search results page, we will primarily use the Product Search API endpoint. This endpoint is designed for complex searching, faceting, and can return Product Projections with tailored data when configured.

Let's break down how each requirement translates into an API query.

1. Base query structure and store filtering

All search queries for Zen Electron will need to be scoped to a specific store. This is achieved by including a filter for the stores field in the main query object. The storeKey would typically be derived from the user's session or the domain they are accessing.
Additionally, we'll use productProjectionParameters to ensure we get localized product data and the correct prices based on the store context.
  • storeProjection: storeKey: This parameter ensures that product data (like names, descriptions) is returned in the locales configured for the specified storeKey. It also influences which attributes and prices might be considered "visible" or "relevant" for that store.
  • Price Selection Parameters (priceCurrency, priceCountry, priceCustomerGroup, priceChannel): These are crucial for getting the correct price. For Zen Electron, these values would often be derived from the storeKey's configuration (for example, a store for "AU" would have priceCurrency: 'AUD' and priceCountry: 'AU'). If a customer is part of a specific customer group or if prices are channel-specific, those IDs would also be passed.
Basic Search Request Body for Zen Electronjson
{
  "query": {
    "filter": [
      {
        "exact": {
          "field": "stores",
          "fieldType": "keyword",
          "value": "your-zen-electron-store-key" // for example, "zen-au", "zen-nz"
        }
      }
      // Full-text search query will be added here
    ]
  },
  "productProjectionParameters": {
    "staged": false, // Show current product data
    "storeProjection": "zen-electron-store-key",
    "priceCurrency": "AUD", // Example, derived from store or user context
    "priceCountry": "AU", // Example, derived from store or user context
    "priceCustomerGroup": "optional-customer-group-id", // If applicable
    "priceChannel": "optional-channel-id" // If applicable
  },
  "limit": 20,
  "offset": 0
  // Sorting and facets will be added here
}
Explanation:
  • The query.filter ensures results are from the specified store.
  • productProjectionParameters.storeProjection tailors localized attributes (name, description) based on the store's languages.
  • productProjectionParameters for price selection ensure the masterVariant.price or variant.price in the returned Product Projection reflects the most relevant price.
  • By using productProjectionParameters, the results array in the response will contain enriched ProductProjection objects, not just IDs. This reduces the need for subsequent API calls to fetch full product details, but leads to higher response times.

3. Sorting

The sort parameter in the ProductSearchRequest takes an array of SearchSorting objects.
// Example "sort" array for different options.
"sort": [
  // By Relevance (Score) - default if no sort is provided, or explicitly:
  // { "field": "score", "order": "desc" }

  // By Newest (createdAt descending)
  // { "field": "createdAt", "order": "desc" }

  // By Oldest (createdAt ascending)
  // { "field": "createdAt", "order": "asc" }

  // By Highest Price (variants.prices.centAmount descending)
  // { "field": "variants.prices.centAmount", "order": "desc", "mode": "max" }

  // By Lowest Price (variants.prices.centAmount ascending)
  { "field": "variants.prices.centAmount", "order": "asc", "mode": "min" }
]

Only pass one of the options at a time, based on what the customer has selected. For example the default sort would be:

"sort": [
  { "field": "score", "order": "desc" }
]

And if they selected highest to lowest price, it would be:

"sort": [
  { "field": "variants.prices.centAmount", "order": "desc", "mode": "max" }
]

Let’s look at the default sort score in the context of the whole query that we have built so far.

"query": {
  "and": [ // Combines store filter with full-text search
    {
      "filter": [ // Store filter remains
        {
          "exact": {
            "field": "stores",
            "fieldType": "keyword",
            "value": "your-zen-electron-store-key"
          }
        }
      ]
    },
    { // Full-text search part
      "or": [ // Match in name OR description
        {
          "fullText": {
            "field": "name",
            "language": "en-AU",
            "value": "user search term",
            "caseInsensitive": true
          }
        },
        {
          "fullText": {
            "field": "description",
            "language": "en-AU",
            "value": "user search term",
            "caseInsensitive": true
          }
        }
      ]
    }
  ],
"sort": [
  { "field": "score", "order": "desc", "mode": "max" }
]
}
Explanation:
  • score: Sorts by relevance, typically used only with full-text search.
  • createdAt: Sorts by product creation date.
  • variants.prices.centAmount: Sorts by price.
    • mode: "min" is used for "lowest price" to consider the minimum price among variants if a product has multiple.
    • mode: "max" is used for "highest price" to consider the maximum price among variants.
  • The specific sort object would be chosen based on user selection from the frontend.

Faceting

Facets provide aggregated counts for refining search results. Zen Electron needs facets for Brand, Price, and Category. These are requested via the facets array in the ProductSearchRequest.
// "facets" array for Brand, Price, and Category
"facets": [
  { // Brand Facet (Distinct Facet on a text attribute)
    "distinct": {
      "name": "brandFacet",
      "field": "variants.attributes.brand.key", // Assuming 'brand' is an enum
      "fieldType": "enum",
      "limit": 10, // Show top 10 brands
      "missing": "N/A" // Bucket for products without a brand
    }
  },
  { // Price Facet (Ranges Facet on a number field)
    "ranges": {
      "name": "priceFacet",
      "field": "variants.prices.centAmount", // Price in cents
      "fieldType": "long", // Or "double" if applicable
      "ranges": [
        { "key": "0-50", "to": 5000 },       // Up to $49.99
        { "key": "50-100", "from": 5000, "to": 10000 }, // $50.00 to $99.99
        { "key": "100-200", "from": 10000, "to": 20000 },// $100.00 to $199.99
        { "key": "200-plus", "from": 20000 } // $200.00 and above
      ]
    }
  },
  { // Category Facet (Distinct Facet on a keyword field)
    "distinct": {
      "name": "categoryFacet",
      "field": "categories.id", // Faceting on category IDs
      "limit": 20,
      "missing": "No Category"
    }
  }
]
Explanation:
  • brandFacet: A distinct facet on variants.attributes.brand.key. The fieldType must match the attribute type (for example, enum, text). limit controls how many brand values are returned at maximum.
  • priceFacet: A ranges facet on variants.prices.centAmount. Each object in ranges defines a price bucket. The key is a user-friendly name for the UI. from is inclusive, to is exclusive. Values are in cents.
  • categoryFacet: A distinct facet on categories.id. This will return category IDs and their counts. The frontend/BFF would need to map these IDs to category names.
The API response will include a facets array with results for each requested facet, showing keys and counts. Example Facet Result Snippet:
"facets": [
  {
    "name": "brandFacet",
    "buckets": [
      { "key": "Sony", "count": 25 },
      { "key": "Samsung", "count": 18 }
    ]
  },
  {
    "name": "priceFacet",
    "buckets": [
      { "key": "0-50", "count": 15 },
      { "key": "50-100", "count": 30 }
    ]
  }
  // ... categoryFacet results
]

Facet filtering (AND vs OR Logic using postFilter)

When a user selects a facet value (for example, Brand "Sony"), the search results should be filtered, but the facet counts for other options (for example, other brands, other price ranges) should still reflect the counts before the "Sony" filter was applied. This is achieved using postFilter.
The postFilter applies to the results of the main query after facets have been calculated.
// Example "postFilter" when user selects Brand "Sony" AND Price Range "$50-$100"
"postFilter": {
  "and": [
    { // Brand filter
      "exact": {
        "field": "variants.attributes.brand.key",
        "fieldType": "enum",
        "value": "Sony"
      }
    },
    { // Price range filter
      "range": {
        "field": "variants.prices.centAmount",
        "fieldType": "long",
        "gte": 5000, // Greater than or equal to 5000 cents
        "lt": 10000  // Less than 10000 cents
      }
    }
  ]
}
Explanation of AND vs OR logic:
  • AND logic (between different facets): When a user selects values from different facets (for example, Brand "Sony" AND Price "$50-$100"), these conditions are combined with an and operator within the postFilter as shown above. The results will only include products that are "Sony" AND fall within the "$50-$100" price range.
  • OR logic (within the same facet): If Zen Electron allows selecting multiple values within the same facet (for example, Brand "Sony" OR Brand "Samsung"), this is also handled in the postFilter.
This is the standard type of logic that most commerce websites use for filtering within and across facets.
// Example "postFilter" for Brand "Sony" OR Brand "Samsung"
"postFilter": {
  "or": [
    {
      "exact": {
        "field": "variants.attributes.brand.key",
        "fieldType": "enum",
        "value": "Sony"
      }
    },
    {
      "exact": {
        "field": "variants.attributes.brand.key",
        "fieldType": "enum",
        "value": "Samsung"
      }
    }
  ]
}
If this OR condition for brands needs to be combined with another facet selection (for example, a price range), the overall postFilter would become a nested structure:
// Brand ("Sony" OR "Samsung") AND Price Range ("$50-$100")
"postFilter": {
  "and": [
    { // OR condition for brands
      "or": [
        { "exact": { "field": "variants.attributes.brand.key", "fieldType": "enum", "value": "Sony" } },
        { "exact": { "field": "variants.attributes.brand.key", "fieldType": "enum", "value": "Samsung" } }
      ]
    },
    { // Price range filter
      "range": { "field": "variants.prices.centAmount", "fieldType": "long", "gte": 5000, "lt": 10000 }
    }
  ]
}

URL parameters integration

The BFF (Backend For Frontend) layer is responsible for translating URL parameters into the Product Search API request body. Zen Electron's URLs might look like: zen-electron.com/en-AU/search?q=laptop&sort=price_asc&brand=Dell&price=500-1000&category=electronics-cat-id

The BFF would parse these parameters:

  • q=laptop: Populates the value in the fullText expressions.
  • sort=price_asc: Determines the sort object (for example, { "field": "variants.prices.centAmount", "order": "asc", "mode": "min" }).
  • brand=Dell: Adds an exact filter for variants.attributes.brand.key with value "Dell" to the postFilter. If multiple brands are selected (for example, brand=Dell,Sony), they'd form an or condition.
  • price=500-1000: Adds a range filter for variants.prices.centAmount (for example, gte: 50000, lt: 100000) to the postFilter.
  • category=electronics-cat-id: Adds an exact filter for categories.id with the value "electronics-cat-id" to the postFilter. Using the category ID is very convenient for building the query, but might be less ideal for SEO or for the customer to read it. So you will most likely want to use the category name. In this case, you will need to have a means of mapping the name to ID. In this document we have talked about a few options for this TODO LINK TO SECTION!

The BFF constructs the complete JSON request body based on these URL parameters and sends it to the Product Search API. When facet options or sort orders are changed on the frontend, the URL is updated and a new API request is made via the BFF.

This approach ensures that search states are bookmarkable, shareable, and support browser back/forward navigation.

Cache search results for enhanced user experience

To further improve performance and user experience, Zen Electron should implement a caching strategy for its search/ listing page. Frequent or common search queries, especially those combined with popular filter sets, can benefit significantly from caching.

How Zen Electron can implement caching

  • BFF layer caching: The Backend For Frontend (BFF) is an ideal place to implement caching. When the BFF receives a request derived from URL parameters, it can first check a cache (for example, Redis, Memcached) for a stored response corresponding to that exact query (including search term, filters, sort order, pagination, and store context).
    • If a valid cached response exists, it's returned directly to the frontend, bypassing the Composable Commerce API.
    • If not, the BFF queries the Product Search API, stores the response in the cache with an appropriate Time-To-Live (TTL), and then returns it to the frontend.
  • CDN caching (for highly common, less dynamic queries): For very popular, non-personalized search landing pages (for example, a search for "best sellers" without user-specific filters), responses could potentially be cached at the CDN level for even faster delivery. This is more applicable if the results don't change frequently.

Why caching benefits Zen Electron

  • Improved Perceived Performance & UX: Cached responses are served much faster, leading to quicker page load times for users, especially for subsequent visits or common searches. This significantly enhances the user experience.

Cache invalidation strategy

Implementing a cache invalidation strategy is crucial for Zen Electron. This could be a simple Time-To-Live (TTL)-based, where entries automatically expire after a set period (e.g., 5-15 minutes).

Caching is most effective when there is a high cache hit ratio. Frequently accessed category pages are likely to achieve high hit ratios. Highly specific queries, such as full-text searches or results refined by many user-selected filters, will have lower hit ratios.

By integrating these Product Search API features with a well-considered caching strategy, Zen Electron can develop a robust, performant, and user-friendly search results page, ensuring it meets business needs and provides an optimal user experience

Test your knowledge