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
anddescription
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)
- Relevance (
- 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
Let's break down how each requirement translates into an API query.
1. Base query structure and store filtering
stores
field in the main query
object. The storeKey
would typically be derived from the user's session or the domain they are accessing.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 specifiedstoreKey
. 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 thestoreKey
's configuration (for example, a store for "AU" would havepriceCurrency: 'AUD'
andpriceCountry: 'AU'
). If a customer is part of a specific customer group or if prices are channel-specific, those IDs would also be passed.
{
"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
}
- 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 themasterVariant.price
orvariant.price
in the returned Product Projection reflects the most relevant price.- By using
productProjectionParameters
, theresults
array in the response will contain enrichedProductProjection
objects, not just IDs. This reduces the need for subsequent API calls to fetch full product details, but leads to higher response times.
2. Full-text search
name
and description
. You achieve this by using the fullText
simple expression. We'll combine these with an or
compound expression if a match in either field is desired. The language
for fullText
should match one of the store's locales.// Adding full-text search to the query object
{
"query": {
"and": [ // Combines store filter with full-text search
{
"exact": { // Store filter remains
"field": "stores",
"fieldType": "keyword",
"value": "your-zen-electron-store-id"
},
},
{ // 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
}
}
]
}
]
}
}
- The main
query
is now anand
to combine the mandatory store filter with the user's full-text search. - The full-text part uses an
or
so products matching the "user search term" in eithername
ordescription
(for the specifiedlanguage
) are returned. caseInsensitive: true
is generally a good practice for user-facing search.- The
language
(for example, "en-AU") should be dynamically set based on the active store/locale.
3. Sorting
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" }
]
}
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
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"
}
}
]
brandFacet
: Adistinct
facet onvariants.attributes.brand.key
. ThefieldType
must match the attribute type (for example,enum
,text
).limit
controls how many brand values are returned at maximum.priceFacet
: Aranges
facet onvariants.prices.centAmount
. Each object inranges
defines a price bucket. Thekey
is a user-friendly name for the UI.from
is inclusive,to
is exclusive. Values are in cents.categoryFacet
: Adistinct
facet oncategories.id
. This will return category IDs and their counts. The frontend/BFF would need to map these IDs to category names.
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)
postFilter
.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
}
}
]
}
-
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 thepostFilter
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
.
// 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"
}
}
]
}
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
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 thevalue
in thefullText
expressions.sort=price_asc
: Determines thesort
object (for example,{ "field": "variants.prices.centAmount", "order": "asc", "mode": "min" }
).brand=Dell
: Adds anexact
filter forvariants.attributes.brand.key
with value "Dell" to thepostFilter
. If multiple brands are selected (for example,brand=Dell,Sony
), they'd form anor
condition.price=500-1000
: Adds arange
filter forvariants.prices.centAmount
(for example,gte: 50000, lt: 100000
) to thepostFilter
.category=electronics-cat-id
: Adds anexact
filter forcategories.id
with the value "electronics-cat-id" to thepostFilter
. 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