We invited our friends at Starschema to write about an example of using Algolia in combination with MongoDB. We hope that you enjoy this four-part series by Full Stack Engineer Soma Osvay.

If you’d like to look back, here are the other links:

Part 1 – Use-case, architecture, and current challenges

Part 2 – Proposed solution and design

Part 3 – Data pipeline implementation


In this post, I will implement the frontend that will consume the Algolia index we created in part 3 of this series. The created web application is available here: https://algolia-listings.starschema.com/. You can try it out with our sample data by default, but if you’ve been following along and experimenting with the format we used in the last article, you can hook up your own index! The code of the application is available on GitHub. A public demo is hosted on StackBlitz.

Step 0. Planning

The frontend of our existing consumer-facing application is written with jQuery and vanilla JavaScript, so I need to make sure that the Algolia sample application that I am developing is compatible with those technologies. Thankfully, Algolia’s InstantSearch.js is compatible with vanilla frontends without any customization, so I am able to use the default sample implementation in the Algolia documentation. Before starting, I drew up a quick sketch of what I wanted to create with the Algolia search UI. I took our existing search page as a baseline and extended it with a few extra features that we’re currently missing:

Frontend wireframe design

I used the official Algolia Getting started guide to set up my project and start coding. The npx create-instantsearch-app created a very well-prepared and well-structured application that I could modify to match my use-case.

Before starting the Algolia specific development, I had to make sure that my Algolia app ID and API key aren’t hard coded in my application so I can share the source code easily. To do this, we created a form that asks for your Algolia credentials and stores them in the browser localStorage to use in queries.

Demo application screenshot

Step 1. Modify HTML and CSS

First, I had to change the layout of the generated index.html file that was created along with the project. This is because I wanted to have a different search layout than Algolia originally created for me based on my UI plans. After a few iterations, I actually deviated from my original plan: I added a Clear Refinements button on the top of the filter section, scrapped pagination because I realized Algolia supports Infinite Scrolling, and I decided to implement GeoSearch (so that our end users can search by location in addition to text). My updated Algolia-specific HTML and CSS looks like this:

<!--Used to host the Algolia search UI-->
<div id="algolia-container">
 <!-- The search box. Located on the top of the page, spanning the entire width -->
 <div id="searchbox"></div>
 <!-- The filters section, located on the left -->
 <div class="filters">
  <h2>Filters</h2>
  <!-- Clear filters button location -->
  <div id="clear-refinements"></div>
  <!-- Country filter location -->
  <h3>Country</h3>
  <div id="country-list"></div>
  <!-- Property Types filter location -->
  <h3>Property Types</h3>
  <div id="property-list"></div>
  <!-- Review scores filter location -->
  <h3>Review Scores</h3>
  <div id="review-scores"></div>
  <!-- Price filter location -->
  <h3>Price</h3>
  <div id="price"></div>
  <!-- Cleaning fee filter location -->
  <h3>Cleaning Fee</h3>
  <div id="cleaning-fee"></div>
 </div>
 <!-- The results panel, located to the right side from the filters -->
 <div class="results">
  <h2>Results</h2>
  <!-- The pagination container, which was later changed to infinite scroll results container -->
  <div class="pagination-container">
   <!-- The container which displays the number of results, and a link which can load the next set of results. These are modified from js -->
   <div id="result-count">
    <span></span>
    <a href="#"></a>
   </div>
   <!-- The location of the control that controls how many results are loaded at once -->
   <div id="per-page"></div>
  </div>
  <!-- The location for the map which shows the results on the world map -->
  <div id="map-display">
   <h3>Map</h3>
   <div id="geo-search"></div>
  </div>
  <!-- The location of the search result details -->
  <div id="details-display">
   <h3>Details</h3>
   <div id="hits"></div>
  </div>
 </div>
</div>

Algolia’s InstantSearch.js is able to create its own controls automatically, so I just have to provide the layout itself.

I also put a little effort into making this look nice with some CSS:

#algolia-container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 1rem;
    display: grid;
    grid-template-columns: 200px 1fr;
    grid-template-rows: auto auto;
    gap: 20px;
}
 
#searchbox {
    grid-column: 1 / 3;
    grid-row: 1;
}
.filters {
    grid-row: 2;
    grid-column: 1;
}
.results {
    grid-row: 2;
    grid-column: 2;
}
.pagination-container {
    display: grid;
    grid-template-columns: 1fr auto auto;
    gap: 20px;
}
 
.ais-GeoSearch-map {
    height: 500px; /* You can change this height */
}

Step 2. Initialize Algolia InstantSearch.js

Next, I initialized the Algolia JS engine that will query my index and display the results.

const {
 algoliasearch,
 instantsearch
} = window;

// Initialize the Algolia Search client and connect to our index
const searchClient = algoliasearch(appId, searchKey);
const search = instantsearch({
 indexName: indexName,
 routing: true,
 searchClient,
});

Step 3. Add widgets

Algolia has designed their frontend functionality into “widgets”, UI components that you can dynamically hydrate empty HTML nodes with. Algolia uses these to add a search box, a filtering and faceting interface, infinite scroll, and many other functionalities. We can add widgets by calling addWidgets and providing the required widgets as an array.

search.addWidgets([
 // The widget definitions go here
]);

I’m going to step through all the widgets I used, but just keep in mind that the following code samples all take place inside that array parameter to addWidgets.

  1. Searchbox. This will add the basic search bar to the page.
    instantsearch.widgets.searchBox({
     container: '#searchbox',
     placeholder: 'Search real-estate listings',
     autofocus: true
    })
    
  2. InfiniteHits. This is where our search results will populate. I used InfiniteHits instead of just Hits to enable infinite scrolling.
    instantsearch.widgets.infiniteHits({
     container: '#hits',
     templates: {
      // Item template will be filled out later
      item: `Sample item template`
     }
    })
    

    This control will contain the item template, which is a piece of HTML code that displays the individual search result. I’ll come back to this — right now there’s just a placeholder there.

  3. Hits per page. This widget allows the user to select the number of items to load at a time.
    instantsearch.widgets.hitsPerPage({
     container: '#per-page',
     items: [{
       label: 'Load 5 results per page',
       value: 5
      },
      {
       label: 'Load 10 results per page',
       value: 10
      },
      {
       label: 'Load 20 results per page',
       value: 20
      },
      {
       label: 'Load 50 results per page',
       value: 50,
       default: true
      },
      {
       label: 'Load 100 results per page',
       value: 100
      },
      {
       label: 'Load 200 results per page',
       value: 200
      }
     ]
    })
    
  4. Clear Filters button. The user can press this button to clear all refinements.
    instantsearch.widgets.clearRefinements({
     container: '#clear-refinements',
     templates: {
      resetLabel: 'Clear filters',
     },
    })
    
  5. Refinement list for property type and country filter. This provides checkboxes that allow users to filter for results that are of specific property types and are located in specific countries.
    instantsearch.widgets.refinementList({
      container: '#property-list',
      attribute: 'property_type'
    }),
    instantsearch.widgets.refinementList({
      container: '#country-list',
      attribute: 'address.country'
    })
    
  6. Rating menu for filtering for listings of certain ratings. I originally wanted to create a simple radio button menu for this, but I realized that Algolia supports this kind of control out-of-the-box.
    instantsearch.widgets.ratingMenu({
     container: '#review-scores',
     attribute: 'scores.stars'
    })
    
  7. Range sliders for a listing’s price and cleaning fee. Originally, I wanted text inputs clamped to a certain range, but I found that sliders are more intuitive to use.
    instantsearch.widgets.rangeSlider({
     container: '#price',
     attribute: 'price',
     precision: 10
    }),
    instantsearch.widgets.rangeSlider({
     container: '#cleaning-fee',
     attribute: 'cleaning_fee',
     precision: 5
    })
    
  8. Map widget for displaying results on the world map. I also added a click event for the markers on the map, which I will later implement to scroll to the clicked listing’s details.
    instantsearch.widgets.geoSearch({
     container: '#geo-search',
     googleReference: window.google,
     initialPosition: {
      lat: 48.864716,
      lng: 2.349014,
     },
     initialZoom: 10,
     builtInMarker: {
      createOptions(item) {
       return {
        title: item.name,
       };
      },
      events: {
       click({
        event,
        item,
        marker,
        map
       }) {
        // to be implemented later
       },
      },
     },
    })
    
  9. I also added a custom connector for infinite hits. Custom connectors let you hijack the functionality of a widget but provide your own HTML, so it’s entirely customizable and dynamic. The reason that I went this route is that Algolia didn’t provide an existing widget to show the result information exactly the way I wanted to due to the layout of our existing search page. Essentially, I give the array of widgets my own custom widget name like this:
    customInfiniteHits({})
    

    Then, outside of the widget array, I create a custom function that runs when the hits change and returns new HTML for the hit list. I configured it to have customized views when there are no search results for the current geo-search or when there are no search results at all. When there are results, it displays the result count and allows the user to request that the next page of results be appended to the bottom of the current page.

    // Define custom logic that runs when the results changed.
    // This will call a function that displays the total result count and allows the user to Load more or clear filters if needed.
    // The hitsChanged method is defined below to keep the initialization code clean.
    // Documentation: <https://www.algolia.com/doc/api-reference/widgets/infinite-hits/js/>
    const renderInfiniteHits = hitsChanged;
    const customInfiniteHits = instantsearch.connectors.connectInfiniteHits(
     renderInfiniteHits
    );
    // This function is called every time the search results change
    // It checks how many results there are in total and either:
    // - displays a 'No results found' message to the user along with the possibility to Clear Filters. It also hides the result container and possibly the map too
    // - displays information about how many total results there are, and how many are currently displayed. It also allows the users to click a 'Load more' button on the top of the page.
    function hitsChanged(renderOptions) {
     // if this is an initial render and we got no results object, skip
     if (!renderOptions.results) {
      return;
     }
     // get the reference for the Result Count elements on the UI (its text and link as well)
     const resultCountElement = document.getElementById('result-count');
     const textEl = resultCountElement.querySelector('span');
     const linkEl = resultCountElement.querySelector('a');
     // check if we have any results
     const hasHits = renderOptions.results.nbHits !== 0;
     // check if we have any search query
     const hasQuery = !!renderOptions.results.query;
     // check if we have any geo bounding box (query made on map)
     const hasGeoQuery = !!renderOptions.results._state.insideBoundingBox;
     // We show/hide result Details depending if we have results or not
     document.getElementById('details-display').style.display = hasHits ? 'block' : 'none';
     // We hide the Map display if we have no results AND there was no map query applied (so the query can be undone)
     document.getElementById('map-display').style.display = hasHits || hasGeoQuery ? 'block' : 'none';
     // Update the Result count container to display information about results
     if (hasHits) {
      // if there are any results
      if (renderOptions.isLastPage) {
       // if we are on last page, show text and a link to clear filters
       textEl.innerText = `Showing all ${renderOptions.hits.length} results`;
       linkEl.style.display = 'none';
       linkEl.innerText = 'Clear all filters';
       linkEl.href = '.';
       linkEl.style.display = 'inline';
       linkEl.onclick = null;
      } else {
       // if we are not on last page, show text and link to load more items
       textEl.innerText = `Showing top ${renderOptions.hits.length} results of ${renderOptions.results.nbHits}`;
       linkEl.href = '';
       linkEl.innerText = 'Load more';
       linkEl.style.display = 'inline';
       linkEl.href = "#";
       linkEl.onclick = () => {
        renderOptions.showMore();
        return false;
       };
      }
     } else {
      // if we have no results, construct message
      let statusMessage = 'No results have been found';
      if (hasQuery) {
       statusMessage += ` for '${renderOptions.results.query}'`;
      }
      if (hasGeoQuery) {
       statusMessage += ' in selected map area';
      }
      // show message and link to clear filters
      textEl.innerText = statusMessage
      linkEl.innerText = 'Clear all filters';
      linkEl.href = '.';
      linkEl.style.display = 'inline';
      linkEl.onclick = null;
     }
    }
    

After adding my widgets, the UI looked like the following:

Demo application UI with new widgets

Step 4. Modify the item template

When adding the widgets, I left the search result template empty, so the item details were not yet displayed properly. I changed the InfiniteHits widget configuration to this:

instantsearch.widgets.infiniteHits({
  container: '#hits',
  templates: {
    // We use no 'empty' template, as our results container will be hidden if there are no results.
    // The item template is heavily modified to draw the details of a listing in detail
    item: `
      <article id="hit-{{objectID}}">
        <div class="name-container">
          <h1 class="name">{{#helpers.highlight}}{ "attribute": "name" }{{/helpers.highlight}}</h1>
          {{#scores}}
            <div class="stars">
              <svg aria-hidden="true">
                {{#scores.has_one}}
                  <use xlink:href="#ais-RatingMenu-starSymbol"></use>
                {{/scores.has_one}}
                {{^scores.has_one}}
                  <use xlink:href="#ais-RatingMenu-starEmptySymbol"></use>
                {{/scores.has_one}}
              </svg>
              <svg aria-hidden="true">
                {{#scores.has_two}}
                  <use xlink:href="#ais-RatingMenu-starSymbol"></use>
                {{/scores.has_two}}
                {{^scores.has_two}}
                  <use xlink:href="#ais-RatingMenu-starEmptySymbol"></use>
                {{/scores.has_two}}
              </svg>
              <svg aria-hidden="true">
                {{#scores.has_three}}
                  <use xlink:href="#ais-RatingMenu-starSymbol"></use>
                {{/scores.has_three}}
                {{^scores.has_three}}
                  <use xlink:href="#ais-RatingMenu-starEmptySymbol"></use>
                {{/scores.has_three}}
              </svg>
              <svg aria-hidden="true">
                {{#scores.has_four}}
                  <use xlink:href="#ais-RatingMenu-starSymbol"></use>
                {{/scores.has_four}}
                {{^scores.has_four}}
                  <use xlink:href="#ais-RatingMenu-starEmptySymbol"></use>
                {{/scores.has_four}}
              </svg>
              <svg aria-hidden="true">
                {{#scores.has_five}}
                  <use xlink:href="#ais-RatingMenu-starSymbol"></use>
                {{/scores.has_five}}
                {{^scores.has_five}}
                  <use xlink:href="#ais-RatingMenu-starEmptySymbol"></use>
                {{/scores.has_five}}
              </svg>
            </div>
          {{/scores}}
        </div>
        {{#description}}
          <div class="description">
            <div class="title">Description{{}}</div>
            <p class="desc">{{#helpers.highlight}}{ "attribute": "description" }{{/helpers.highlight}}</p>
          </div>
        {{/description}}
        {{#summary}}
          <div class="summary">
            <div class="title">Summary</div>
            <p class="desc">{{#helpers.highlight}}{ "attribute": "summary" }{{/helpers.highlight}}</p>
          </div>
        {{/summary}}
        {{#space}}
          <div class="space">
            <div class="title">Space</div>
            <p class="desc">{{#helpers.highlight}}{ "attribute": "space" }{{/helpers.highlight}}</p>
          </div>
        {{/space}}
        {{#neighborhood}}
          <div class="neigh">
            <div class="title">Neighborhood</div>
            <p class="desc">{{#helpers.highlight}}{ "attribute": "neighborhood_overview" }{{/helpers.highlight}}</p>
          </div>
        {{/neighborhood}}
        {{#transit}}
          <div class="transit">
            <div class="title">Transit</div>
            <p class="desc">{{#helpers.highlight}}{ "attribute": "transit" }{{/helpers.highlight}}</p>
          </div>
        {{/transit}}
        <div class="info">
          {{#property_type}}
            <div>
              <span class="title">Property Type:</span>
              <span>{{property_type}}</span>
            </div>
          {{/property_type}}
          {{#address}}
            <div>
              <span class="title">Address:</span>
              <span>{{#helpers.highlight}}{ "attribute": "address.street" }{{/helpers.highlight}}</span>
            </div>
          {{/address}}
          {{#price}}
            <div>
              <span class="title">Price:</span>
              <b>{{price}}$ per night</b>
              {{#cleaning_fee}}
                <span> + {{cleaning_fee}}$ cleaning fee</span>
              {{/cleaning_fee}}
              {{#security_deposit}}
                <span> + {{security_deposit}}$ security deposit</span>
              {{/security_deposit}}
            </div>
          {{/price}}
          {{#accommodates}}
            <div>
              <span class="title">Accommodates:</span>
              <b>{{accommodates}} people</b>
              {{#bedrooms}}
                <span> in {{bedrooms}} bedroom(s)</span>
              {{/bedrooms}}
              {{#beds}}
                <span>, {{beds}} bed(s)</span>
              {{/beds}}
              {{#bathrooms}}
                <span> with {{bathrooms}} bathroom(s)</span>
              {{/bathrooms}}
            </div>
          {{/accommodates}}
        </div>
        {{#images.picture_url}}
          <img class="image" src="{{images.picture_url}}">
        {{/images.picture_url}}
      </article>
  `
  }
})

The Algolia item template uses Mustache for adding variables. It is important to add the variables as attributesToRetrieve when you are creating the index so that they are present in the code when Algolia constructs the item’s HTML. I also added the following CSS to display the items properly:

article {
    display: grid;
    width: 100%;
    grid-template-columns: 1fr 400px;
    column-gap: 20px;
    grid-template-rows: auto auto auto auto auto auto auto 1fr;
}
 
article .title {
    margin-top: 8px;
    font-size: 14px;
    color: gray;
}
article p {
    display: block;
    margin: 0px;
}
 
article .name-container {
    grid-row: 1;
    grid-column: 1 / 3;
}
article .name-container > * {
    display: inline-block;
    vertical-align: middle;
}
 
.stars {
    margin-left: 8px;
    height: 24px;
}
.stars svg {
    width: 24px;
    height: 24px;
    display: inline-block;
    fill: yellowgreen;
}
 
article .description {
    grid-row: 2;
    grid-column: 1;
}
article .summary {
    grid-row: 3;
    grid-column: 1;
}
article .space {
    grid-row: 4;
    grid-column: 1;
}
article .neigh {
    grid-row: 5;
    grid-column: 1;
}
article .transit {
    grid-row: 6;
    grid-column: 1;
}
article .info {
    grid-row: 7;
    grid-column: 1;
}
article .info > * {
    margin-top: 8px;
}
article .image {
    grid-column: 2;
    grid-row: 2 / 9;
    margin-top: 8px;
    width: 100%;
}
article .properties {
    display: grid;
    grid-template-columns: auto auto;
    gap: 4px;
    grid-auto-flow: column;
    width: fit-content;
}
article .properties .key {
    color: gray;
    font-size: 12px;
}
article .properties .value {
    font-size: 12px;
}

And now the search results look like this:

Demo application UI final search results

Lessons learned

Overall, I found that the Algolia InstantSearch.js framework provides an incredibly flexible way of creating your own search interfaces in your web applications. The UI building feels smooth, and the widget system is a wonderful tool to build good-looking interfaces very quickly. Using the custom connectors, I also realized that Algolia can fit into more complex use-cases as well, which require a more customized approach. The generated HTML can be styled easily to look much different than the prototype.

My entire Algolia frontend implementation was a few hundred lines of code, compared to the thousands of lines I would have had to write if I was going to implement this kind of capability myself. Most of the code was taken up by my custom generalization and connector logic, and Algolia let me tie all of those complex features in with the default InstantSearch.js widgets with just a few commands.

The searching is astonishingly fast, the index outperforms my expectations by quite a bit. The average search or filter action shows a result within 60ms, much quicker than our usual expectation of a whole second.

Overall conclusion

During this proof of concept, I found that:

  • The search speed and accuracy of Algolia outperforms any existing solutions I have seen so far.
  • The Algolia index has a straightforward construction, not requiring deep technical knowledge.
  • Search priorities, rankings, geo-searching, and other attributes are customizable and don’t require extensive maintenance.
  • The Algolia search index seamlessly integrates into our existing data pipeline. It also exposes a great Python API, so the scripts can be written and maintained by our Python-loving data engineers.
  • The Algolia frontend SDKs are very well-documented. I showed my application to some of our frontend developers and after checking the code, they were very excited about using InstantSearch.js themselves.
  • Since Algolia is cloud-hosted, it keeps our infrastructure team happy, as there is no need to introduce another system into our environment. It scales automatically and the SLAs offered match our requirements.
  • We don’t have to modify our application backend at all, which saves us a lot of time and cost.

Based on these findings, I will definitely recommend to my team that we use Algolia in production.

Recommended Articles

Powered by Algolia AI Recommendations

Get the AI search that shows users what they need