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:
  • Install Next.js
  • Add tailwind
  • Create a sample Elasticsearch database
  • Install missing dependencies
  • Create frontend page
  • Create API
  • Update frontend page to implement autocomplete
  • Update API to implement lookalike
  • Update frontend page to implement lookalike
  • 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.

    27

    This website collects cookies to deliver better user experience

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