DEV Community

Cover image for Song search application built using Typesense.
Pramit Marattha for Aviyel Inc

Posted on

Song search application built using Typesense.

Crafting search engines from absolute scratch which are typo-tolerant, effective, and efficient is really very difficult. A typographical error could cause a search to return nothing, even if the requested item is in the database. By obviating the need to develop a search engine, Typesense could save a lot of time and effort. Your users will also be able to use the search feature in your app effectively, resulting in a positive user experience. Typesense is an open-source typo-tolerant search engine for developers, designed to cut down on the time it takes to find information quickly. To learn more about typesense, go to the following link.

This article will walk you through installing Typesense, creating a new application from scratch, preconfiguring the client, and more. This post will also show you how to construct a Typesense Collection. Finally, we’ll start our app and initialize it, then add an item to our collection and start searching.

Building a Song Search Application from scratch !

Typesense Configuration

Typesense can be used by installing its prebuilt docker image which is already provided to us by Typesense or by using the Typesense cloud hosting solution, which is the most straightforward way to get started. To get started, go to the Typesense cloud website and sign up with your Github account, or use the docker method easily and directly. We’ll use the Docker method for the purposes of this tutorial.To follow along with this tutorial, go to the Typesense Dockerhub and download the prebuilt docker image, then follow the instructions below.

docker pull typesense/typesense
Enter fullscreen mode Exit fullscreen mode

and

mkdir /tmp/typesense-data
docker run -p 8108:8108 -v/tmp/data:/data typesense/typesense:0.22.2 --data-dir /data --api-key=songsearch --enable-cors --listen-port 8108
Enter fullscreen mode Exit fullscreen mode

Docker run

Docker desktop

Building Search UIs from scratch.

Using the open source InstantSearch.js library library or its React, Vue, and Angularjs, as well as the Typesense-InstantSearch-Adapter, you can create a plug-and-play full-featured search interface with just a few lines of code.

Finally, lets’s actually build a search UI for our Application .

Step-by-step instructions for installing

Let’s begin with a basic template:

npx create-instantsearch-app typesense-songsearch
Enter fullscreen mode Exit fullscreen mode
? The name of the application or widget typesense-songsearch
? InstantSearch template InstantSearch.js

Creating a new InstantSearch app in typesense-songsearch.

? InstantSearch.js version 4.38.0
? Application ID latency
? Search API key 6be0576ff61c053d5f9a3225e2a90f76
? Index name instant_search
? Attributes to display
  Used to generate the default result template
? Attributes to display
  Used to filter the search interface Dynamic widgets

📦  Installing dependencies...

yarn install v1.22.0
info No lockfile found.
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Saved lockfile.

Done in 173.57s.

🎉  Created typesense-songsearch at typesense-songsearch.

Begin by typing:

  cd typesense-songsearch
  yarn start

⚡️  Start building something awesome!
Enter fullscreen mode Exit fullscreen mode

Instantsearch app setup

Instantsearch app setup

This is how your folder structure should look after you’ve finished installing your Instantsearch app (UI).

Folder structure

For the npx create-instantsearch-app command, here are some setup suggestions: Any of the web libraries supported by typesense can be used, including InstantSearch.jsReactVue, and Angular. The default version of InstantSearch.js can be used. The application ID can be any string; we’ll be changing it later in the article. Search API key: any string - later in the guide, we’ll replace this with the Typesense Search-only API Key and Our index name is the name of the collection in Typesense, and lastly , leave the Displaying attributes as it is.

Next, we’ll need to install the Typesense InstantSearch adapter to use InstantSearch with a Typesense backend:

npm install typesense-instantsearch-adapter
Enter fullscreen mode Exit fullscreen mode

typesense instantsearch adapter

Installing Typesense

npm install typesense
Enter fullscreen mode Exit fullscreen mode

Install typesense

We’re ready to create a Typesense collection, Now that everything is installed and running, we can create a Typesense collection, index some documents in it, and try searching for them.

Your package.json file should look like this after you’ve installed all of the necessary dependencies.

{
  "name": "typesense-songsearch",
  "version": "1.0.0",
  "private": true,
  "main": "src/app.js",
  "scripts": {
    "start": "parcel index.html --port 3000",
    "build": "parcel build index.html",
    "lint": "eslint .",
    "lint:fix": "npm run lint -- --fix"
  },
  "devDependencies": {
    "babel-eslint": "10.0.3",
    "eslint": "5.7.0",
    "eslint-config-algolia": "13.2.3",
    "eslint-config-prettier": "3.6.0",
    "eslint-plugin-import": "2.19.1",
    "eslint-plugin-prettier": "3.1.2",
    "parcel-bundler": "1.12.5",
    "prettier": "1.19.1"
  },
  "dependencies": {
    "algoliasearch": "4",
    "instantsearch.js": "4.38.0",
    "typesense": "^0.14.0",
    "typesense-instantsearch-adapter": "^2.2.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, it’s a very basic app with only two important files: app.js and index.html, which contains the structure of our entire app.

In the index.html file, you can see an id. We can use those ids to attach UI components such as a searchboxresults, and facets if you want to include them, as well as pagination widgets.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <meta name="theme-color" content="#000000" />

    <link rel="manifest" href="./manifest.webmanifest" />
    <link rel="shortcut icon" href="./favicon.png" />

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/instantsearch.css@7/themes/algolia-min.css"
    />
    <link rel="stylesheet" href="./src/index.css" />
    <link rel="stylesheet" href="./src/app.css" />

    <title>typesense-songsearch</title>
  </head>

  <body>
    <header class="header">
      <h1 class="header-title">
        <a href="/">typesense-songsearch</a>
      </h1>
      <p class="header-subtitle">
        using
        <a href="https://github.com/algolia/instantsearch.js">
          InstantSearch.js
        </a>
      </p>
    </header>

    <div class="container">
      <div class="search-panel">
        <div class="search-panel__filters">
          <div id="dynamic-widgets"></div>
        </div>

        <div class="search-panel__results">
          <div id="searchbox"></div>
          <div id="hits"></div>
        </div>
      </div>

      <div id="pagination"></div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/algoliasearch@4.10.5/dist/algoliasearch-lite.umd.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/instantsearch.js@4.38.0"></script>
    <script src="./src/app.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

As you can see below, the app.js file is configured for algolia, and we’ll need to change that for typesense.

// app.js
const { algoliasearch, instantsearch } = window;

const searchClient = algoliasearch("latency", "songsearch");

const search = instantsearch({
  indexName: "instant_search",
  searchClient,
});
search.addWidgets([
  instantsearch.widgets.searchBox({
    container: "#searchbox",
  }),
  instantsearch.widgets.hits({
    container: "#hits",
  }),
  instantsearch.widgets.configure({
    facets: ["*"],
    maxValuesPerFacet: 20,
  }),
  instantsearch.widgets.dynamicWidgets({
    container: "#dynamic-widgets",
    fallbackWidget({ container, attribute }) {
      return instantsearch.widgets.refinementList({
        container,
        attribute,
      });
    },
    widgets: [],
  }),
  instantsearch.widgets.pagination({
    container: "#pagination",
  }),
]);

search.start();
Enter fullscreen mode Exit fullscreen mode

To use InstantSearch with a Typesense backend, we’ll need to install the Typesense InstantSearch adapter. To do so, simply open a terminal window and type the following command into it.

If you’ve already installed it, you can skip this step.

npm install --save typesense-instantsearch-adapter
Enter fullscreen mode Exit fullscreen mode

Typesense adapter installation

Finally, the project can be adjusted to use Typesense. In the background, we’ve got our typesense instance up and running. Open src/app.js and change the way InstantSearch is initialized to get InstantSearch.js to use the Typesense adapter:

const searchClient = algoliasearch("latency", "songsearch");

const search = instantsearch({
  indexName: "instant_search",
  searchClient,
});
Enter fullscreen mode Exit fullscreen mode

to this

const typesenseInstantsearchAdapter = new TypesenseInstantSearchAdapter({
  server: {
    apiKey: "songsearch",
    nodes: [
      {
        host: "localhost",
        port: "8108",
        protocol: "http",
      },
    ],
  },
  additionalSearchParameters: {
    queryBy: "title,primary_artist_name,album_name", //quering by
  },
});
const searchClient = typesenseInstantsearchAdapter.searchClient;

const search = instantsearch({
  searchClient,
  indexName: "songs",
});
Enter fullscreen mode Exit fullscreen mode

Now we need to import our songs data, so create a dataset folder and inside it, create your own songs json file, filling it with all of the necessary song information, or download the songs dataset from here. Finally, your folder structure should look something like this.

Folder structure

loaddata

Let’s get to work on the data-importing scripts. We’ll start by creating a file called loadData.js and we will export and initialize the typesense client inside but before we do that we need to install a few packages lodash and fast-json-stringify.

npm i lodash
Enter fullscreen mode Exit fullscreen mode

lodash

and

npm i fast-json-stringify
Enter fullscreen mode Exit fullscreen mode

JSON Stringify

So, at the very top, import all of the dependencies that this script requires to run, as shown below.

// loadData.js
const _ = require("lodash");
const fastJson = require("fast-json-stringify");
const fs = require("fs");
const readline = require("readline");
const Typesense = require("typesense");
Enter fullscreen mode Exit fullscreen mode

After that, Create a stringify based on the schema of the documents that need to be stringified.

// loadData.js
const _ = require("lodash");
const fastJson = require("fast-json-stringify");
const fs = require("fs");
const readline = require("readline");
const Typesense = require("typesense");

const stringify = fastJson({
  title: "Song Schema",
  type: "object",
  properties: {
    track_id: {
      type: "string",
    },
    title: {
      type: "string",
    },
    album_name: {
      type: "string",
      nullable: true,
    },
    primary_artist_name: {
      type: "string",
    },
    genres: {
      type: "array",
      items: {
        type: "string",
      },
    },
    country: {
      type: "string",
    },
    release_date: {
      type: "integer",
    },
    release_decade: {
      type: "string",
    },
    release_group_types: {
      type: "array",
      items: {
        type: "string",
      },
    },
    urls: {
      type: "array",
      items: {
        type: "object",
        properties: {
          type: {
            type: "string",
          },
          url: {
            type: "string",
          },
        },
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Create a new extractUrls function.

function extractUrls(parsedRecord) {
  return parsedRecord["relations"]
    .filter((r) =>
      [
        "amazon asin",
        "streaming",
        "free streaming",
        "download for free",
        "purchase for download",
      ].includes(r["type"])
    )
    .map((r) => {
      return { type: r["type"], url: r["url"]["resource"] };
    });
}
Enter fullscreen mode Exit fullscreen mode

Now create an asynchronous addSongsToTypesense function and copy the code exactly as it is shown below. This function will simply index all of the songs into Typesense, and if an error occurs during the process, it will simply log an error.

async function addSongsToTypesense(songs, typesense, songsCollection) {
  try {
    const returnDataChunks = await Promise.all(
      _.chunk(songs, Math.ceil(songs.length / CHUNK_SIZE)).map((songsChunk) => {
        const jsonlString = songsChunk
          .map((song) => stringify(song))
          .join("\n");

        return typesense
          .collections(songsCollection)
          .documents()
          .import(jsonlString);
      })
    );

    const failedItems = returnDataChunks
      .map((returnData) =>
        returnData
          .split("\n")
          .map((r) => JSON.parse(r))
          .filter((item) => item.success === false)
      )
      .flat();
    if (failedItems.length > 0) {
      throw new Error(
        `Error indexing items ${JSON.stringify(failedItems, null, 2)}`
      );
    }
  } catch (error) {
    console.log(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

A Collection in Typesense is a set of related Documents that functions similarly to a table in a relational database. We give a collection a name and describe the fields that will be indexed when a document is added to the collection when we create it.

Now go to the loadData.js file and add the following changes to the code.

module.exports = (async () => {
  const typesense = new Typesense.Client({
    nodes: [
      {
        host: "localhost",
        port: "8108",
        protocol: "http",
      },
    ],
    apiKey: "songsearch",
  });

  const songsCollection = "songs";
  const schema = {
    name: songsCollection,
    fields: [
      { name: "track_id", type: "string" },
      { name: "title", type: "string" },
      { name: "album_name", type: "string", optional: true },
      { name: "primary_artist_name", type: "string", facet: true },
      { name: "genres", type: "string[]", facet: true },
      { name: "country", type: "string", facet: true },
      { name: "release_date", type: "int64" },
      { name: "release_decade", type: "string", facet: true },
      { name: "release_group_types", type: "string[]", facet: true },
    ],
    default_sorting_field: "release_date",
  };

  console.log(`Populating data in Typesense "${songsCollection}" collection`);

  console.log(`Creating schema...`);
  await typesense.collections().create(schema);

  console.log(`Songs records...`);

  const fileStream = fs.createReadStream(DATA_FILE);
  const rl = readline.createInterface({
    input: fileStream,
    crlfDelay: Infinity,
  });

  let songs = [];
  let currentLine = 0;
  for await (const line of rl) {
    currentLine += 1;
    const parsedRecord = JSON.parse(line);
    try {
      songs.push(
        ...parsedRecord["media"]
          .map((media) => media["tracks"])
          .flat()
          .filter((track) => track) // To remove nulls
          .map((track) => {
            const releaseDate =
              Math.round(
                Date.parse(
                  parsedRecord["release-group"]["first-release-date"]
                ) / 1000
              ) || 0;

            // Be sure to update the schema passed to stringify when updating this structure
            const song = {
              track_id: track["id"],
              title: track["title"],
              album_name: parsedRecord["title"],
              primary_artist_name:
                parsedRecord["artist-credit"][0]["artist"]["name"],
              genres: [
                ...track["recording"]["genres"].map((g) => g.name),
                ...parsedRecord["genres"].map((g) => g.name),
                ...parsedRecord["release-group"]["genres"].map((g) => g.name),
              ].map(
                ([firstChar, ...rest]) =>
                  firstChar.toUpperCase() + rest.join("").toLowerCase()
              ),
              country: parsedRecord["country"] || "Unknown",
              release_date: releaseDate,
              release_decade: `${
                Math.round(new Date(releaseDate * 1000).getUTCFullYear() / 10) *
                10
              }s`,
              release_group_types: [
                parsedRecord["release-group"]["primary-type"] || "Unknown",
                parsedRecord["release-group"]["secondary-types"] || null,
              ]
                .flat()
                .filter((e) => e),
              urls: extractUrls(parsedRecord),
            };
            process.stdout.write("-");

            return song;
          })
      );
    } catch (e) {
      console.error(e);
      console.error(parsedRecord);
      throw e;
    }

    if (currentLine >= MAX_LINES) {
      break;
    }
  }

  if (songs.length > 0) {
    await addSongsToTypesense(songs, typesense, songsCollection);
    console.log("ALL songs INDEXED ✅ !");
  }
})();
Enter fullscreen mode Exit fullscreen mode

If an error occurs while loading the data, simply add the following snippet of code to the loadData.js file before creating songSchema.

loadData

await typesense.collections("songs").delete();
Enter fullscreen mode Exit fullscreen mode

This is how your final code should look like.

// loadData.js
const _ = require("lodash");
const fastJson = require("fast-json-stringify");
const fs = require("fs");
const readline = require("readline");
const Typesense = require("typesense");

const stringify = fastJson({
  title: "Song Schema",
  type: "object",
  properties: {
    track_id: {
      type: "string",
    },
    title: {
      type: "string",
    },
    album_name: {
      type: "string",
      nullable: true,
    },
    primary_artist_name: {
      type: "string",
    },
    genres: {
      type: "array",
      items: {
        type: "string",
      },
    },
    country: {
      type: "string",
    },
    release_date: {
      type: "integer",
    },
    release_decade: {
      type: "string",
    },
    release_group_types: {
      type: "array",
      items: {
        type: "string",
      },
    },
    urls: {
      type: "array",
      items: {
        type: "object",
        properties: {
          type: {
            type: "string",
          },
          url: {
            type: "string",
          },
        },
      },
    },
  },
});

const BATCH_SIZE = 200;
const CHUNK_SIZE = 3;
const MAX_LINES = Infinity;
const DATA_FILE = "./dataset/songs.json";

function extractUrls(parsedRecord) {
  return parsedRecord["relations"]
    .filter((r) =>
      [
        "amazon asin",
        "streaming",
        "free streaming",
        "download for free",
        "purchase for download",
      ].includes(r["type"])
    )
    .map((r) => {
      return { type: r["type"], url: r["url"]["resource"] };
    });
}

async function addSongsToTypesense(songs, typesense, songsCollection) {
  try {
    const returnDataChunks = await Promise.all(
      _.chunk(songs, Math.ceil(songs.length / CHUNK_SIZE)).map((songsChunk) => {
        const jsonlString = songsChunk
          .map((song) => stringify(song))
          .join("\n");

        return typesense
          .collections(songsCollection)
          .documents()
          .import(jsonlString);
      })
    );

    const failedItems = returnDataChunks
      .map((returnData) =>
        returnData
          .split("\n")
          .map((r) => JSON.parse(r))
          .filter((item) => item.success === false)
      )
      .flat();
    if (failedItems.length > 0) {
      throw new Error(
        `Error indexing items ${JSON.stringify(failedItems, null, 2)}`
      );
    }
  } catch (error) {
    console.log(error);
  }
}

module.exports = (async () => {
  const typesense = new Typesense.Client({
    nodes: [
      {
        host: "localhost",
        port: "8108",
        protocol: "http",
      },
    ],
    apiKey: "songsearch",
  });

  await typesense.collections("songs").delete();

  const songsCollection = "songs";
  const schema = {
    name: songsCollection,
    fields: [
      { name: "track_id", type: "string" },
      { name: "title", type: "string" },
      { name: "album_name", type: "string", optional: true },
      { name: "primary_artist_name", type: "string", facet: true },
      { name: "genres", type: "string[]", facet: true },
      { name: "country", type: "string", facet: true },
      { name: "release_date", type: "int64" },
      { name: "release_decade", type: "string", facet: true },
      { name: "release_group_types", type: "string[]", facet: true },
    ],
    default_sorting_field: "release_date",
  };

  console.log(`Populating data in Typesense "${songsCollection}" collection`);

  console.log(`Creating schema...`);
  await typesense.collections().create(schema);

  console.log(`Songs records...`);

  const fileStream = fs.createReadStream(DATA_FILE);
  const rl = readline.createInterface({
    input: fileStream,
    crlfDelay: Infinity,
  });

  let songs = [];
  let currentLine = 0;
  for await (const line of rl) {
    currentLine += 1;
    const parsedRecord = JSON.parse(line);
    try {
      songs.push(
        ...parsedRecord["media"]
          .map((media) => media["tracks"])
          .flat()
          .filter((track) => track) // To remove nulls
          .map((track) => {
            const releaseDate =
              Math.round(
                Date.parse(
                  parsedRecord["release-group"]["first-release-date"]
                ) / 1000
              ) || 0;

            // Be sure to update the schema passed to stringify when updating this structure
            const song = {
              track_id: track["id"],
              title: track["title"],
              album_name: parsedRecord["title"],
              primary_artist_name:
                parsedRecord["artist-credit"][0]["artist"]["name"],
              genres: [
                ...track["recording"]["genres"].map((g) => g.name),
                ...parsedRecord["genres"].map((g) => g.name),
                ...parsedRecord["release-group"]["genres"].map((g) => g.name),
              ].map(
                ([firstChar, ...rest]) =>
                  firstChar.toUpperCase() + rest.join("").toLowerCase()
              ),
              country: parsedRecord["country"] || "Unknown",
              release_date: releaseDate,
              release_decade: `${
                Math.round(new Date(releaseDate * 1000).getUTCFullYear() / 10) *
                10
              }s`,
              release_group_types: [
                parsedRecord["release-group"]["primary-type"] || "Unknown",
                parsedRecord["release-group"]["secondary-types"] || null,
              ]
                .flat()
                .filter((e) => e),
              urls: extractUrls(parsedRecord),
            };
            process.stdout.write("-");

            return song;
          })
      );
    } catch (e) {
      console.error(e);
      console.error(parsedRecord);
      throw e;
    }

    if (currentLine >= MAX_LINES) {
      break;
    }
  }

  if (songs.length > 0) {
    await addSongsToTypesense(songs, typesense, songsCollection);
    console.log("ALL songs INDEXED ✅ !");
  }
})();
Enter fullscreen mode Exit fullscreen mode

Finally, type node loadData.js into the terminal of that same project directory, and you have successfully imported 1 thousand songs documents into the search index.

LoadData

Now, you can start your application by typing npm start in the terminal of that project directory.

npm start

We need to fix the fact that the application functions perfectly but no information, as well as data, is displayed in it.

Demo

So, to fix it, go to our app.js file and remove a few of the widgets that we don’t need, so the app.js file should look something like this.

// app.js
const { instantsearch } = window;

import TypesenseInstantSearchAdapter from "typesense-instantsearch-adapter";

const typesenseInstantsearchAdapter = new TypesenseInstantSearchAdapter({
  server: {
    apiKey: "songsearch",
    nodes: [
      {
        host: "localhost",
        port: "8108",
        protocol: "http",
      },
    ],
  },
  additionalSearchParameters: {
    queryBy: "title,primary_artist_name,album_name", //quering by
  },
});
const searchClient = typesenseInstantsearchAdapter.searchClient;

const search = instantsearch({
  searchClient,
  indexName: "songs",
});

search.addWidgets([
  instantsearch.widgets.searchBox({
    container: "#searchbox",
  }),
  instantsearch.widgets.hits({
    container: "#hits",
  }),
  instantsearch.widgets.pagination({
    container: "#pagination",
  }),
]);

search.start();
Enter fullscreen mode Exit fullscreen mode

Simply re-run the application after you’ve fixed it, and your application should now look like this.

Start Server

Data

You can now build a search interface using any of the InstantSearch widgets, and we’ll be adding a template to present the data in a pleasing manner.Simplycopy the template code below and add it inside the #hits widget present inside app.js file.

templates: {
item: `
<h2>
{ "attribute": "title" }
</h2>
<div>
by
<a style="color:#FF6F82">{ "attribute": "primary_artist_name" }</a>
</div>
<div>
Album: <a style="color:#8661d1" >{ "attribute": "album_name" }</a>
</div>
<div class="text-muted small mb-2">
</div> `,
},
Enter fullscreen mode Exit fullscreen mode

app.js file should resemble something like this.

// App.js
const { instantsearch } = window;

import TypesenseInstantSearchAdapter from 'typesense-instantsearch-adapter';

const typesenseInstantsearchAdapter = new TypesenseInstantSearchAdapter({
  server: {
    apiKey: 'songsearch',
    nodes: [
      {
        host: 'localhost',
        port: '8108',
        protocol: 'http',
      },
    ],
  },
  additionalSearchParameters: {
    queryBy: 'title,primary_artist_name,album_name', //quering by
  },
});
const searchClient = typesenseInstantsearchAdapter.searchClient;

const search = instantsearch({
  searchClient,
  indexName: 'songs',
});

search.addWidgets([
  instantsearch.widgets.searchBox({
    container: '#searchbox',
  }),
  instantsearch.widgets.hits({
    container: '#hits',
    templates: {
      item: `
      <h2>
        { "attribute": "title" }
      </h2>
      <div>
        by
        <a style="color:#FF6F82">{ "attribute": "primary_artist_name" }</a>
      </div>
      <div>
        Album: <a style="color:#8661d1" >{ "attribute": "album_name" }</a>
      </div>
      <div class="text-muted small mb-2">

      </div>
      `,
    },
  }),
  instantsearch.widgets.pagination({
    container: '#pagination',
  }),
]);

search.start();
Enter fullscreen mode Exit fullscreen mode

After you’ve fixed it, re-run the application, and your app should now look like this.

Demo

Simply add the following code above the #hits widgets inside the app.js file to configure the 20 hits per page or to configure to your own requirements.

instantsearch.widgets.configure({
    hitsPerPage: 20,
  }),
Enter fullscreen mode Exit fullscreen mode

So now let’s try to add the facets, to do that we’ll add an instant search widgets .In the instantsearch library this is called refinementlist and finally we have to specify the container, in our case let’s call it a #genreFilter,#artistFilter and then the attribute we want to take, in our case let’s call it genres and primary_artist_name respectively. So, our final code should look like this.

// app.js
const { instantsearch } = window;

import TypesenseInstantSearchAdapter from "typesense-instantsearch-adapter";

const typesenseInstantsearchAdapter = new TypesenseInstantSearchAdapter({
  server: {
    apiKey: "songsearch",
    nodes: [
      {
        host: "localhost",
        port: "8108",
        protocol: "http",
      },
    ],
  },
  additionalSearchParameters: {
    queryBy: "title,primary_artist_name,album_name", //quering by
  },
});
const searchClient = typesenseInstantsearchAdapter.searchClient;

const search = instantsearch({
  searchClient,
  indexName: "songs",
});

search.addWidgets([
  instantsearch.widgets.searchBox({
    container: "#searchbox",
  }),
  instantsearch.widgets.configure({
    hitsPerPage: 20,
  }),
  instantsearch.widgets.hits({
    container: "#hits",
    templates: {
      item: `
      <h2>
        { "attribute": "title" }
      </h2>
      <div>
        by
        <a style="color:#FF6F82">{ "attribute": "primary_artist_name" }</a>
      </div>
      <div>
        Album: <a style="color:#8661d1" >{ "attribute": "album_name" }</a>
      </div>
      <div class="text-muted small mb-2">

      </div>
      `,
    },
  }),
  instantsearch.widgets.refinementList({
    container: "#genreFilter",
    attribute: "genres",
    searchable: true,
    searchablePlaceholder: "Search genres",
    showMore: true,
  }),
  instantsearch.widgets.refinementList({
    container: "#artistFilter",
    attribute: "primary_artist_name",
    searchable: true,
    searchablePlaceholder: "Search Artist",
    showMore: true,
  }),
  instantsearch.widgets.pagination({
    container: "#pagination",
  }),
]);

search.start();
Enter fullscreen mode Exit fullscreen mode

and our index.html file should look something like this.

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <meta name="theme-color" content="#000000" />

    <link rel="manifest" href="./manifest.webmanifest" />
    <link rel="shortcut icon" href="./favicon.png" />

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/instantsearch.css@7/themes/algolia-min.css"
    />
    <link rel="stylesheet" href="./src/index.css" />
    <link rel="stylesheet" href="./src/app.css" />

    <title>typesense-songsearch</title>
  </head>

  <body>
    <header class="header">
      <h1 class="header-title">
        <a href="/">typesense-songsearch</a>
      </h1>
      <p class="header-subtitle">
        using
        <a href="https://github.com/algolia/instantsearch.js">
          InstantSearch.js
        </a>
      </p>
    </header>

    <div class="container">
      <div class="search-panel">
        <div class="search-panel__filters">
          <div id="genreFilter"></div>
          <br />
          <div id="artistFilter"></div>
        </div>

        <div class="search-panel__results">
          <div id="searchbox"></div>
          <div id="hits"></div>
        </div>
      </div>

      <div id="pagination"></div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/algoliasearch@4.10.5/dist/algoliasearch-lite.umd.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/instantsearch.js@4.38.0"></script>
    <script src="./src/app.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

This is how your application should look like.

Final Demo

Finally, we can add sorting functionality to the app by repeating the exact steps above: add the sortBy widget, specify the container with #sort-songs, specify the items with the label default with the value songs, and then create another label called Recent songs with the value songs/sort/release_date:asc, and another label called Oldest songs with the value songs/sort/release_date:dsc

 instantsearch.widgets.sortBy({
    container: '#sort-songs',
    items: [
      { label: 'Default', value: `songs` },
      { label: 'Recent songs', value: `songs/sort/release_date:asc` },
      { label: 'Oldest songs', value: `songs/sort/release_date:desc` },
    ],
  }),
Enter fullscreen mode Exit fullscreen mode

Finally, update and add sort-songs id before the searchbox inside index.html file.

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <meta name="theme-color" content="#000000" />

    <link rel="manifest" href="./manifest.webmanifest" />
    <link rel="shortcut icon" href="./favicon.png" />

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/instantsearch.css@7/themes/algolia-min.css"
    />
    <link rel="stylesheet" href="./src/index.css" />
    <link rel="stylesheet" href="./src/app.css" />

    <title>typesense-songsearch</title>
  </head>

  <body>
    <header class="header">
      <h1 class="header-title">
        <a href="/">typesense-songsearch</a>
      </h1>
      <p class="header-subtitle">
        using
        <a href="https://github.com/algolia/instantsearch.js">
          InstantSearch.js
        </a>
      </p>
    </header>

    <div class="container">
      <div class="search-panel">
        <div class="search-panel__filters">
          <div id="genreFilter"></div>
          <br />
          <div id="artistFilter"></div>
        </div>

        <div class="search-panel__results">
          <div id="sort-songs"></div>
          <div id="searchbox"></div>
          <div id="hits"></div>
        </div>
      </div>

      <div id="pagination"></div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/algoliasearch@4.10.5/dist/algoliasearch-lite.umd.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/instantsearch.js@4.38.0"></script>
    <script src="./src/app.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Let’s take a look at the finished product of our typesense-integrated songsearch application.

Final Demo

The entire source code of the application can be found here

Typesense was built with several distinctive features primarily aimed at making the developer’s job easier while also giving customer as well as user the ability to provide a better search experience as possible.This article may have been entertaining as well as instructive in terms of how to create a fullstack song search app using typesense from the ground. Join Aviyel’s community to learn more about the open source project, get tips on how to contribute, and join active dev groups.

Aviyel is a collaborative platform that assists open source project communities in monetizing and long-term sustainability. To know more visit Aviyel.com and find great blogs and events, just like this one! Sign up now for early access, and don’t forget to follow us on our socials!

Top comments (0)