Create a lookalike search engine with Next.js, Tailwind and Elasticsearch (10 steps)
Tags: react, typescript, nextjs, elasticsearch
Published: 2022-01-03 22:00:13

In this post you will learn how to create a website that displays books _similar to_ a selected book from scratch, using Next.js (React), Tailwind and Elasticsearch. Go to end of the post to check result.

### List of steps:

1. Install Next.js
2. Add tailwind
3. Create a sample Elasticsearch database
4. Install missing dependencies
5. Create frontend page
6. Create API
7. Update frontend page to implement autocomplete
8. Update API to implement lookalike
9. Update frontend page to implement lookalike
10. Test

## 1. Install Next.js
First create your [Next.js](https://nextjs.org/docs/getting-started) app:
```
npx create-next-app@latest --typescript lookalike-search-engine
```
Then run it:
```
cd lookalike-search-engine
npm run dev
```
Then you can goto http://localhost:3000 to see the welcome page.

## 2. Add tailwind
Install [tailwind](https://tailwindcss.com/docs/guides/nextjs):
```
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
```
Edit `tailwind.config.js`:
```diff
module.exports = {
+  content: [
+    "./pages/**/*.{js,ts,jsx,tsx}",
+    "./components/**/*.{js,ts,jsx,tsx}",
+  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
```

Replace `styles/globals.css` with:

```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

Replace `pages/index.tsx` with:

```tsx
import type { NextPage } from "next";

const Home: NextPage = () => {
  return (
    <h1 className="text-3xl font-bold underline">
      Hello world!
    </h1>
  );
};

export default Home;
```

Delete `styles/Home.module.css` and `pages/api/hello.ts`.


## 3. Create a sample Elasticsearch database

Install Elasticsearch (MacOS: `brew tap elastic/tap` then `brew install elastic/tap/elasticsearch-full`, other: [see Elasticsearch docs](https://www.elastic.co/guide/en/elasticsearch/reference/current/install-elasticsearch.html)).

Run [create-elasticsearch-dataset](https://github.com/rap2hpoutre/create-elasticsearch-dataset) to create a sample database with 6800 books:

```
npx create-elasticsearch-dataset --dataset=books
```

Goto http://localhost:9200/books/_search?pretty to check that the Elasticsearch `books` index has been created.

## 4. Install missing dependencies
Install [react-select](https://react-select.com/home) and [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/installation.html) dependencies:

```
npm install @elastic/elasticsearch react-select
```

## 5. Create frontend page

We need a page that displays a search bar with autocomplete (`AsyncSelect` component) and the selected book displayed in a box.

![Front page step 1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8v8583p5ot8x1j00vdne.png)
 

We will create it without an API for now, with fake data.

Replace `pages/index.tsx` with:

```tsx
import React from "react";
import type { NextPage } from "next";
import Head from "next/head";
import AsyncSelect from "react-select/async";

interface Book {
  _id: string;
  title: string;
  authors: string;
  description: string;
}

const testBook: Book = {
  _id: "1",
  title: "The Lord of the Rings",
  authors: "J.R.R. Tolkien",
  description: "A classic book",
};

const Home: NextPage = () => {
  return (
    <div>
      <Head>
        <title>Lookalike search engine</title>
      </Head>
      <div className="container mx-auto p-5">
        <AsyncSelect
          defaultOptions
          isClearable={true}
          placeholder="Start typing a book name..."
          onChange={async () => {}}
          loadOptions={async () => {}}
        />
        <div className="py-7">
          <Book book={testBook} />
        </div>
      </div>
    </div>
  );
};

function Book({ book }: { book: Book }) {
  return (
    <div
      key={book._id}
      className="border rounded-md shadow px-3 py-2"
    >
      <div className="text-lg text-bold py-2">
        {book.title}{" "}
        <span className="text-sm text-gray-500 ml-3">
          {book.authors}
        </span>
      </div>
      <div className="text-sm text-gray-700">
        ℹ️ {book.description}
      </div>
    </div>
  );
}

export default Home;
```

## 6. Create API

Create `pages/api/autocomplete.ts` that will return the result displayed in the search bar (autocomplete aka typeahead or combobox).

This page will be called with a query string:

```rest
GET /api/autocomplete?query=rings%20lord
```

It should return the first 10 books that contains `rings` and lord:

```json
[
  {"_id": "30", "title": "The Lord of the Rings"},
  {"_id": "765", "title": "The Art of The Lord of the Rings"}
]
```

Create `pages/api/autocomplete.ts`:

```ts
import { Client } from "@elastic/elasticsearch";
import type { NextApiRequest, NextApiResponse } from "next";

// Return data from elasticsearch
const search = async (
  req: NextApiRequest,
  res: NextApiResponse
) => {
  const { query } = req.query;
  const client = new Client({
    node: "http://localhost:9200",
  });
  const r = await client.search({
    index: "books",
    size: 10,
    body: {
      query: {
        match_bool_prefix: {
          title: { operator: "and", query },
        },
      },
    },
  });
  const {
    body: { hits },
  } = r;
  return res
    .status(200)
    .json(
      hits.hits.map((hit: any) => ({
        _id: hit._id,
        ...hit._source,
      }))
    );
};

export default search;
```

## 7. Update frontend page to implement autocomplete

![Autocomplete](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6o4r1yv44l27sqzmomw0.gif) 

Call the API from `pages/index.tsx` in order to make the autocomplete work.

```tsx
import React, { useState } from "react";
import type { NextPage } from "next";
import Head from "next/head";
import AsyncSelect from "react-select/async";

interface Book {
  _id: string;
  title: string;
  authors: string;
  description: string;
}

const Home: NextPage = () => {
  const [currentBook, setCurrentBook] =
    useState<Book | null>(null);

  return (
    <div>
      <Head>
        <title>Lookalike search engine</title>
      </Head>
      <div className="container mx-auto p-5">
        <AsyncSelect
          defaultOptions
          isClearable={true}
          placeholder="Start typing a book name..."
          onChange={async (newValue: any) => {
            setCurrentBook(newValue?.value || null);
          }}
          loadOptions={async (inputValue: string) => {
            if (inputValue.length < 2) return;
            const response = await fetch(
              `/api/autocomplete?query=${inputValue}`
            );
            const data = await response.json();
            return data.map((item: Book) => ({
              value: item,
              label: (
                <>
                  {item.title}
                  <span className="text-gray-400 text-sm ml-3">
                    {item.authors}
                  </span>
                </>
              ),
            }));
          }}
        />
        <div className="py-7">
          {currentBook !== null && (
            <Book book={currentBook} />
          )}
        </div>
      </div>
    </div>
  );
};

function Book({ book }: { book: Book }) {
  return (
    <div
      key={book._id}
      className="border rounded-md shadow px-3 py-2"
    >
      <div className="text-lg text-bold py-2">
        {book.title}{" "}
        <span className="text-sm text-gray-500 ml-3">
          {book.authors}
        </span>
      </div>
      <div className="text-sm text-gray-700">
        ℹ️ {book.description}
      </div>
    </div>
  );
}

export default Home;
```

## 8. Update API to implement lookalike

Use the `more_like_this` specialized query [provided by Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-mlt-query.html) in order to display similar result as the one we selected in autocomplete.

So, create a new `pages/api/lookalike.ts` page that 10 most similar results.

This page will be called with a query string:

```rest
GET /api/lookalike?id=12345
```

It should return the first 10 books that are similar to 12345 document:

```json
[
  {"_id": "30", "title": "The Lord of the Rings"},
  {"_id": "765", "title": "The Art of The Lord of the Rings"}
]
```

Create `pages/api/lookalike.ts`:

```ts
import { Client } from "@elastic/elasticsearch";
import type { NextApiRequest, NextApiResponse } from "next";

const search = async (
  req: NextApiRequest,
  res: NextApiResponse
) => {
  const id: string = req.query.id as string;
  const client = new Client({
    node: "http://localhost:9200",
  });
  const { body: similar } = await client.search({
    index: "books",
    body: {
      size: 12,
      query: {
        more_like_this: {
          fields: [
            "title",
            "subtitle",
            "authors",
            "description",
          ],
          like: [
            {
              _index: "books",
              _id: id,
            },
          ],
          min_term_freq: 1,
          max_query_terms: 24,
        },
      },
    },
  });
  res.status(200).json(
    similar.hits.hits.map((hit: any) => ({
      _id: hit._id,
      ...hit._source,
    }))
  );
};

export default search;
```

## 9. Update frontend page to implement lookalike

Call the new API route each time an book is selected in autocomplete. Then, display the similar book right after the "original" one. In order to help the users understand the similarity, we could highlight the result with yellow color.

```jsx
import React, { useState } from "react";
import type { NextPage } from "next";
import Head from "next/head";
import AsyncSelect from "react-select/async";

interface Book {
  _id: string;
  title: string;
  authors: string;
  description: string;
}

const Home: NextPage = () => {
  const [currentBook, setCurrentBook] = useState<Book | null>(null);
  const [similarBooks, setSimilarBooks] = useState<Book[]>([]);

  return (
    <div>
      <Head>
        <title>Lookalike search engine</title>
      </Head>
      <div className="container mx-auto p-5">
        <AsyncSelect
          defaultOptions
          isClearable={true}
          placeholder="Start typing a book name..."
          onChange={async (newValue: any) => {
            if (!newValue) {
              setSimilarBooks([]);
              setCurrentBook(null);
              return;
            }
            const response = await fetch(
              `/api/lookalike?id=${newValue.value._id}`
            );
            const data = await response.json();
            setSimilarBooks(data);
            setCurrentBook(newValue.value);
          }}
          loadOptions={async (inputValue: string) => {
            if (inputValue.length < 2) return;
            const response = await fetch(
              `/api/autocomplete?query=${inputValue}`
            );
            const data = await response.json();
            return data.map((item: Book) => ({
              value: item,
              label: (
                <>
                  {item.title}
                  <span className="text-gray-400 text-sm ml-3">
                    {item.authors}
                  </span>
                </>
              ),
            }));
          }}
        />
        <div className="py-7">
          {currentBook !== null && <Book book={currentBook} />}
          {similarBooks.length > 0 && (
            <>
              <h1 className="text-2xl mt-5 mb-2">Lookalike books</h1>
              <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
                {similarBooks.map((entry: Book) => (
                  <Book book={entry} key={entry._id} />
                ))}
              </div>
            </>
          )}
        </div>
      </div>
    </div>
  );
};

function Book({ book }: { book: Book }) {
  return (
    <div key={book._id} className="border rounded-md shadow px-3 py-2">
      <div className="text-lg text-bold py-2">
        {book.title}{" "}
        <span className="text-sm text-gray-500 ml-3">{book.authors}</span>
      </div>
      <div className="text-sm text-gray-700">ℹ️ {book.description}</div>
    </div>
  );
}

export default Home;
```

## 10. Test

Goto http://localhost:3000/ and test.

![Result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/06q4usoz8zexftmwopc4.gif)

Voilà. Feel free to ask questions in the comment section.