19
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.
- 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
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.
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
.
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.
Install react-select and elasticsearch dependencies:
npm install @elastic/elasticsearch react-select
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;
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;
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;
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;
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;
Goto http://localhost:3000/ and test.
Voilà. Feel free to ask questions in the comment section.
19