Create a lookalike search engine with Next.js, Tailwind and Elasticsearch (10 steps)

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 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:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Edit tailwind.config.js:

module.exports = {
+  content: [
+    "./pages/**/*.{js,ts,jsx,tsx}",
+    "./components/**/*.{js,ts,jsx,tsx}",
+  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Replace styles/globals.css with:

@tailwind base;
@tailwind components;
@tailwind utilities;

Replace pages/index.tsx with:

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).

Run 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 and elasticsearch 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.

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

Replace pages/index.tsx with:

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:

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

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

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

Create pages/api/autocomplete.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

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

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 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:

GET /api/lookalike?id=12345

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

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

Create pages/api/lookalike.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.

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.

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

19