Aug 2023

Algolia + NextJS for Ecommerce

Many ecommerce sites will have a product card which displays variantions of an item like color and size.
T-shirts with color variants

To setup, you will need to:

  1. Create a record for each variant.
  2. Turn on Distinct (distinct) and choose an attibute to group variants (attributeForDistinct).
  3. To display meaningful information for each variant - like price and an image - you will need to duplicate the data so that each variant record contains data for every one of it's siblings.

Your data will look like something like:

[
  {
    "is_default": true,
    "group_sku": "AB",
    "sku": "AB1",
    "color": "blue",
    "variants": [
      { "sku": "AB1", "color": "blue", "price": 100 },
      { "sku": "AB2", "color": "yellow", "price": 100 },
    ]
  },
  {
    "is_default": false,
    "group_sku": "AB",
    "sku": "AB2",
    "color": "yellow",
    "variants": [
      { "sku": "AB1", "color": "blue", "price": 100 },
      { "sku": "AB2", "color": "yellow", "price": 100 },
    ]
  }
]

👉 Filtering won't remove irrelevant variants from a record. For example, if you filter by blue, yellow will still show up. You'll need to remove yellow with application code.

💡 To control which variant shows up as the primary record, add a strict sort to Ranking and Sorting (SORT BY is_default_varaint).

You choose which attributes get searched and in which order ("Searcheable attributes"). A typical example might be:

  1. Brand
  2. SKU
  3. Type, Appearance
  4. Color

The default ranking mechanism is roughly:

  • typo: Results with more typos are demoted.
  • words: Number of words in the query which match at least once.
  • filters: Score of filters when using filters or merchandising ("rule") boosts.
  • exact: Number of query words matching exactly.

You can layer on custom signals to further influence the order of results ("Ranking and Sorting"), in two flavors:

  1. Custom rankings (relative boosts like Solr's bq parameter). Useful for boosting in-stock, popular, new products, etc.
  2. Strict sort. Generally you want to avoid strict sort, as it overrides sort-by-relevance. One exception is if you are rolling up variants and would like to control which variant is the primary one.

Debugging search relevance is intuitive and painless. The ranking popup in the dashboard which explains why a specific record ranks the way it does relative to other records.

Debugging relevance in Algolia's dashboard

Algolia's approach to configuring and tweaking search relevance feels like it's at the right layer of abstraction. Simple to understand, sensible defaults, yet customizable. Contrast with Solr/Elastic Search, which requires the user to fiddle with low level primitives like tokenizers, ngrams and pesky math.

For more on Lucene based search engines, see Relevant Search and my demo search engine.

Any attribute that will be used as a filter needs to be added to the Facets list. Filters come with counts and are searchable.
Brand filter example

If you have chosen to roll up variants, your options are:

  1. The default behavior of having all filters reflect variant counts.
    • 👎 Users will see inflated counts in filters which won't reflect the number of items on the page.
  2. Set facetingAfterDistinct globally1 to only show and count filter values which are part of the primary variant.
    • 👎 Values which don't show up as a primary variant for a given set of results will be ignored.
  3. Apply afterDistinct2 to individual filters. Only those filters will reflect the primary variant.

🚦 Algolia has a widget to display preset ranges. The problem is that a) it's single select, b) doesn't show counts and c) will show options that lead to zero results. The recommended workaround is to create a new attribute at index time to bucket the filter values and display a regular facet.

Each sort requires a new virtual index. For example, you will need separate indices for price ascending and price descending.

🚦 Merchandising rules (and other settings) get copied over to new sort indices and can potentially interfere with sorting. Make sure to remove them as needed.

Merchandising ("Rules") come with a rich set of features. Rules can be triggered by a search term, filter or a context (for example mobile/desktop). It can also be limited by a date range - useful for seasonal promotions.

Conveniently, query suggestions can be merchandised as well.

A rule can have multiple consequences:

  • Pin/hide products
  • Boost/bury by any filterable attribute (category, brand, style, etc).
  • Filter results
  • Redirect
  • Control which filters should display
  • Control how filter values should display (pin, sort, etc)

Keep in mind that:

  1. Rules have no notion of hierarchy. A sub-category will not inherit from a rule from its parent category.
  2. Only one rule will match at a time. You can't have one rule control pinnning and another control boosting. Rule precedence logic arbitrates which rule gets actived.

Because of these contraints, if you have a general rule (for example, in_stock = true) with a bunch of consequences and a more specific rule (for example, in_stock = true AND color = 'green') with an additional consequence, you will need to copy over the consequences from the general rule to the more specific rule.

I prefer to avoid rules for configuring filter display logic. Filter display is rarely modified and is no big deal to roll-your-own.3

👉 In some scenarios, you will need to actively monitor pinned items: If you have a filter trigger (for example, in_stock = true) which pins 10 items to the top of a list and some items go out of stock, their positions will become "orphaned" and be filled by random items. There is no automatic way to keep all the pinned items clustered at the top of the list.

Manual Rules

The previous section described "visual" rules.

Confusingly there is another type of rule - "manual rule" - with some overlapping functionality but also different features. A manual rule can:

  • Be triggered by detecting a facet value in a query4
  • Dynamically add query parameters
  • Remove/replace words
  • Replace an entire query
  • Create a conditionless rule5

📍 Visual and manual rules cannot coexist for the same triggers; you will have to choose one or the other.

Algolia provides a ton of data about how a user interacts with search:

  • Which categeries were visited
  • Which filters were clicked on
  • Top search terms
  • Searches without clicks
  • Searches without results
  • Click through rate
  • Conversion rate

📍 Conversions don't diffrentiate between add-to-cart and a purchase. Ideally, they would be separate events.

You can further enrich the data by providing custom analyticsTags6. For example:

  • Tag which queries are coming from autocomplete or from the list page.
  • Tag queries by category page.
  • A/B test a new user experience.

Algolia uses these signals for dynamic reranking, personalization and Recommend.

The Search REST API is the core of Algolia Search. Around it, Algolia built a complete ecosystem, of libraries, tools, and a dashboard. You should use the official API clients and libraries to implement Algolia. They're all open source, and the code is available on GitHub.

There's no SLA if you use the REST API directly.

Quotes from here.

The offical clients come with per-session in-memory caching, network retries for flakey connections, multi-query API requests, and much more.

JS libraries: Autocomplete, InstantSearch, Search Insights

NextJS/React libraries: react-instantsearch, react-instantsearch-router-nextjs

Autocomplete is for search and autocomplete functionality, while InstantSearch responsible for displaying a list page with filters and products. Autocomple can be used together with InstantSearch for list pages or as a standalone component. Both libraries come in styled and non-styled (headless) modes and can be fully customized.

InstantSearch

InstantSearch derives the app state from widgets/hooks which are mounted to the DOM. More specifically, when the filters are on the page and you make a selection, that selection is reflected in the URL and the filter badges. If you remove the filters, the selection is cleared. The offical workaround is to hide filters with CSS as needed. See here and here and a React flavor of the same advice.

One consequence of this architecture is that you cannot use UI libraries that remove elements from the DOM. For example, you cannot use Algolia hooks inside Radix UI's dialog component.

Another consequence is that in order to get server-side-rendering (SSR) working for NextJS, you need to (somewhat confusingly):

  1. Extract the Algolia state by calling getServerState with your entire app.
  2. Feed that state to InstantSearchSSRProvider.
const serverState = await getServerState(<App />, {
  renderToString,
});

👉 Since you are running your app server side solely to get the Algolia state, you only care about rendering Algolia hooks and can avoid any other expensive side effects7.

In my experience:

  • Afer a page refresh, don't add or remove hooks (the same goes for getServerState). For example, if you want to display a series filter only if a brand selection was made, render both filters and hide the series filter until it is needed.
  • Don't call the same exact hook more than once. For example, if you need useRefinementList({ attribute: 'price' }) in two different places, call it once at the root of your app.
  • Sometimes you want to calculate the UI by taking a look at the data returned by all filters at the same time8. However, merging data from a bunch of hooks is painful and brittle.
  • When navigating between pages that have different hooks/filters, you can either use NexJS's router (<Link> and router.push) or force a full refresh (window.location.href = url). If navigating with NextJS results in a janky experience, always force a full page refresh.

Algolia has usage-based-billing, see the docs for optimizations tips.

💡 You may be tempted to save on usage costs by calling the API's directly server side and cache responses. However, you will miss out on analytics, which is an important component of the product.

Verify in the network tab that each user action (click on filter, search, etc) results in a single multi-query API call.

Autocomplete will trigger an API call for each key stroke unless you debounce the input. See this article. Debouncing works with plugins as well:

autocomplete({
  container: '#autocomplete',
  plugins: myPlugins,
  getSources({ query }) {
    return debounced([])
  },
})

  1. https://www.algolia.com/doc/api-reference/api-parameters/facetingAfterDistinct/

  2. https://www.algolia.com/doc/api-reference/api-parameters/attributesForFaceting/#parameter-option-afterdistinct

  3. Besides, any time you want to create a more specific boost/bury, you'll need to copy over the filter display logic to the more specific rule.

  4. https://www.algolia.com/doc/guides/solutions/ecommerce/filtering-and-navigation/tutorials/auto-selected-facets/

  5. https://www.algolia.com/doc/guides/managing-results/rules/rules-overview/how-to/use-conditionless-rules/

  6. https://www.algolia.com/doc/api-reference/api-parameters/analyticsTags/

  7. https://www.algolia.com/doc/api-reference/widgets/server-state/react/#widget-param-children

  8. For example, if you have a filter group called Size which contains Width and Length filters. You might want to hide the filter group if there are no values for any of the filters. A possible workaround is doing something like .filter-group::has(.filters:empty) { display: none }.