ElasticSearch is a popular search engine that sits next to an application's primary data store. This is particularly interesting to Makerkit's users as Firestore does not have excellent search capabilities; therefore, we need a search engine that sits next to it.
In this article, we will learn how to use ElasticSearch and Next.js together, so you can deploy them for your SaaS.
To demonstrate ElasticSearch's capabilities, we will be indexing documents written to our Firestore database and implementing a search input in our UI that allows us to pull the results from ElasticSearch.
This tutorial assumes we have a working Next.js application running. If you need any help with setting that up, you can check the following articles:
Why use ElasticSearch?
ElasticSearch is a capable search engine that works very well for various use cases, such as:
- full-text search
- custom weighted search
- data analytics
In a typical SaaS, ElasticSearch could be deployed for various reasons, for example:
- storing and retrieving structured analytical data (clicks, views, sessions, or any sort of data) for creating interactive dashboards
- storing unstructured data (such as free text), and then allowing users to search through it and return the most relevant documents
Given that databases such as Firebase Firestore do not support full-text search and complex analytical aggregations, ElasticSearch could be a great option to place beside the primary datastore.
Running ElasticSearch with Docker
The easiest way to install ElasticSearch is using the Docker Image.
If you have Docker installed and running, add the Docker configuration below to your project:
version: '2.2'services: es01: image: docker.elastic.co/elasticsearch/elasticsearch:7.17.0 container_name: es01 environment: - node.name=es01 - cluster.name=es-docker-cluster - discovery.seed_hosts=es02 - cluster.initial_master_nodes=es01,es02 - bootstrap.memory_lock=true - "ES_JAVA_OPTS=-Xms512m -Xmx512m" ulimits: memlock: soft: -1 hard: -1 volumes: - data01:/usr/share/elasticsearch/data ports: - 9200:9200 networks: - elastic es02: image: docker.elastic.co/elasticsearch/elasticsearch:7.17.0 container_name: es02 environment: - node.name=es02 - cluster.name=es-docker-cluster - discovery.seed_hosts=es01,es03 - cluster.initial_master_nodes=es01,es02 - bootstrap.memory_lock=true - "ES_JAVA_OPTS=-Xms512m -Xmx512m" ulimits: memlock: soft: -1 hard: -1 volumes: - data02:/usr/share/elasticsearch/data networks: - elastic kib01: image: docker.elastic.co/kibana/kibana:7.17.0 container_name: kib01 ports: - 5601:5601 environment: ELASTICSEARCH_URL: http://es01:9200 ELASTICSEARCH_HOSTS: '["http://es01:9200","http://es02:9200"]' networks: - elasticvolumes: data01: driver: local data02: driver: local data03: driver: localnetworks: elastic: driver: bridge
You can now run the ELK stack from your IDE or from your terminal. If the latter, use the command below:
docker compose up
To test that the stack is working, check if Kibana is running at http://localhost:5601.
It may take a while before it's up and running, so don't worry if it doesn't work right away
Running ElasticSearch in a Next.js Application
Creating the ElasticSearch Client
ElasticSearch comes with an official Node.js client that makes it relatively easy to work with, typically versioned with the same version as its server counterpart (Elastic.co version).
To install it, run the following command:
npm i @elasticsearch/client --save
Now we can create a function to initialize the ElasticSearch client:
async function createElasticSearchClient(options: ClientOptions) { const { Client } = await import('@elastic/elasticsearch'); return new Client(options);}
The above is pretty simple, but as you can see, we have to provide the options that include the host and the credentials to the ElasticSearch server. Additionally, we provide the local environment host we will use during development.
Let's assume the function getOptions
can retrieve the configuration from the environment variables. These options have the following interface:
{ node?: string; cloud?: string; password?: string; username?: string;}
The cloud
property is used by elastic.co for identifying the node to connect to, so we add it to the example if you choose to build a deployment using the service.
Otherwise, you can use the property node
to point to an Elastic server. Instead, we point to our Docker instance if cloud
is not provided.
const DEFAULTS: ClientOptions = { node: 'http://localhost:9200',};function getElasticOptions(): ClientOptions { // remember to define "getOptions" to get your credentials! const { cloud, password, username } = getOptions(); // this branch is for prod (i.e. cloud is provided) if (cloud) { return { cloud: { id: cloud, }, auth: { username, password, }, }; } // this branch is for dev return { node: DEFAULTS.node, };}
We dynamically import the Elastic client so that it will be loaded only if used. Now, we can create a factory to get an elastic client:
export function getElasticClient() { return createElasticSearchClient( getElasticOptions() );}
For the sake of this blog post, I export the function from a file named elastic.ts
:
import { ClientOptions } from '@elastic/elasticsearch';async function createElasticSearchClient(options: ClientOptions) { const { Client } = await import('@elastic/elasticsearch'); return new Client(options);}const DEFAULTS: ClientOptions = { node: 'http://localhost:9200',};export function getElasticClient() { return createElasticSearchClient(getElasticOptions());}function getElasticOptions(): ClientOptions { // this branch is for dev return { node: DEFAULTS.node, };}
Indexing documents with the Node.js ElasticSearch client
The process of indexing documents means we're adding data to our ElasticSearch database, which will, in turn, analyze and ingest it. After getting indexed by ElasticSearch, the document will become searchable.
ElasticSearch can automatically understand your data's shape when the index is created by indexing a document. With that said, if the data type of the documents is a bit complex (for example, it has nested arrays), you will need to pre-define the schema before it is created.
Indexing a document in ElasticSearch is fairly simple. For example, let's index a simple JSON document using the index
method:
async function indexBlogPost(post: BlogPost) { const elastic = await getElasticClient(); return elastic.index({ index: `blog-posts`, body: post, });}
Using the index
method above, we have:
- Created an index named
blog-posts
(if this did not exist yet) - Added a document containing the properties of
post
A practical example: automatically indexing Firestore documents in ElasticSearch
As Firebase's limited search functionality requires a different search engine, we need to work it around by indexing each document written to the database.
To automate the process, we can use Firestore Triggers
: these triggers allow us to fire a function every time a document is written or deleted.
Listening to Firestore document updates
Let's assume we are writing to a Firestore path tasks/{taskId}
. To receive updates for the path's documents, we can use the Cloud Function below:
import { firestore } from 'firebase-functions';export const onTaskCreated = firestore .document('tasks/{taskId}') .onWrite((change, context) => { // do something here });
Writing Firestore documents to ElasticSearch
Now, let's write the logic to push our data to ElasticSearch:
import { firestore } from 'firebase-functions';export const onTaskCreated = firestore .document('tasks/{taskId}') .onWrite(async (change) => { const task = change.after.data(); if (!task) { return; } // decorate Task data with its ID task.id = change.after.id; const elastic = await getElasticClient(); await elastic.index({ body: task, index: `tasks`, }); console.log(`Task successfully indexed`); });
After creating a document, we can check if it gets created by navigating to Kibana. For example, if you have created an index pattern, you can check it out in the Discover Tab in Kibana.
You should suffix your index names for splitting indexes; alternatively, consider the Lifecycle API that will do it for you based on index size or document age.
Then, we can target the indexes we search using an index pattern: let's assume we name our indexes tasks-0001
and tasks-0002
, we can search our index using the pattern tasks-*
.
Creating a Next.js API endpoint to query ElasticSearch
Now that we can automatically sync up Elastic and Firestore, we want to be able to query Elastic's documents using an API function.
To do so, we will write a Next.js endpoint that will fetch the documents from ElasticSearch and return them to the client.
Before doing that, though, let's see how we would write an ElasticSearch query to fetch tasks by their name
field:
POST tasks*/_search{ "query": { "match": { "name": "<QUERY>" } }}
Now, let's write the same query using the ElasticSearch Node.js client. We create the API function at api/tasks/search.ts
with the following content:
async function tasksSearchHandler( req: NextApiRequest, res: NextApiResponse) { const { q: query } = req.query; // validate the "q" query parameter exists and is a string if (!query || typeof query !== 'string') { return res.status(400).end(); } const elastic = await getElasticClient(); const result = await elastic.search({ index: 'tasks*', query: { match: { name: query, }, }, }); // we send back the list of documents found const tasks = result.hits.hits.map((item) => item._source); return res.json(tasks);}
Fetching ElasticSearch documents from the Next.js client
Let's create a hook to fetch the data after the user runs a search.
NB: this is a highly simplified version for fetching data. Best to use libraries such as SWT, React-query, or Makerkit's own useApiRequest
if you use it.
function useSearchTasks() { return useCallback((query: string) => { return fetch(`/api/tasks/search?q=${query}`) .then(r => r.json()); }, []);}
Displaying results in a Next.js application
Now, we can query the data from our React components.
- First, we want to create a search bar:
function SearchInput(props: React.PropsWithChildren<{ onSearch: (query: string) => void;}>) { return ( <form onSubmit={e => { e.preventDefault(); const query = new FormData(e.currentTarget).get('query'); props.onSearch(query as string); }}> <input name='query' placeholder='Search task...' /> <button>Search Tasks</button> </form> );}
- Finally, we can add the
SearchInput
to theTasksContainer
component, which is responsible for fetching and displaying the tasks if any was found.
function TasksContainer() { const [tasks, setTasks] = useState<Task[]>(); const searchTasks = useSearchTasks(); const onSearch = useCallback( async (query: string) => { const tasks = await searchTasks(query); setTasks(tasks); }, [searchTasks] ); return ( <> <div>Type below to search for your tasks:</div> <SearchInput onSearch={onSearch} /> {tasks && tasks.length ? <TasksList tasks={tasks} /> : <NoTaskFound />} </> );}
🥳 And here is the final result!