In the following tutorial, we'll be learning how to implement full-text search using Next.js, Contentful, and Algolia. The results will be displayed in real-time as the search box input changes. I've decided not to include any styling in this tutorial, but feel free to add stylig as you go if you feel like it.
Prerequisites
- A Next.js project: You can either use an existing project or create a new one by running
npx create-next-app
oryarn create-next-app
. - A Contentful account, data, Space Id and Access Token: If you're new to Contentful, learn how to get your API keys and set up and fetch your data here.
- An Algolia account: Create a free account here.
- A local.env file: Create the file as the root of your project and add your Contentful
SPACE_ID
andACCESS_TOKEN
to it.
Setting up Algolia
Writing the Script
At the root of your project, create a scripts
folder. Inside the folder, create a file named build-search.js
. Paste the code below into your file. Install graphql-request
and algoliasearch/lite
either through yarn or npm. If your Contentful data includes rich text, also install @contentful/rich-text-plain-text-renderer
.
Add NEXT_PUBLIC_ALGOLIA_APP_ID and ALGOLIA_SEARCH_ADMIN_KEY to your env.local
file. To get the keys, from your Algolia dashboard, click "API keys" in the menu on the left. Grab the "Search-Only API Key" and the "Admin API Key".
//I added the following two lines because my env variables
//weren't working
const path = require("path");
require("dotenv").config({ path: path.resolve(__dirname, "../.env.local") });
//To handle rich text
const richTextPlainTextRenderer = require("@contentful/rich-text-plain-text-renderer");
//To fetch Contentful data
const { GraphQLClient, gql } = require("graphql-request");
const algoliasearch = require("algoliasearch/lite");
//Fetch Contentful data
const callContentful = async () => {
const endpoint = `https://graphql.contentful.com/content/v1/spaces/${process.env.SPACE_ID}`;
const graphQLClient = new GraphQLClient(endpoint, {
headers: {
authorization: `Bearer ${process.env.ACCESS_TOKEN}`,
},
});
const query = gql`
<your GraphQL query goes here>
`;
const data = await graphQLClient.request(query);
//returns array of posts
return data.bluecodeArticleCollection.items;
};
//Algolia accepts data in JSON format
function transformPostsToSearchObjects(posts) {
const transformed = posts.map((post) => {
//This is just an example
//Customise the code below according to your query data
return {
objectID: post.sys.id,
title: post.title,
slug: post.slug,
genre: post.genre,
date: post.date,
body: richTextPlainTextRenderer.documentToPlainTextString(
post.article.json
),
};
});
return transformed;
}
(async function () {
try {
const posts = await callContentful();
const transformed = transformPostsToSearchObjects(posts);
const client = algoliasearch(
process.env.NEXT_PUBLIC_ALGOLIA_APP_ID,
process.env.ALGOLIA_SEARCH_ADMIN_KEY
);
//you can can chose any name you like instead of
//"full_text_search"
const index = client.initIndex("full_text_search");
const algoliaResponse = await index.saveObjects(transformed);
console.log(
`🎉 Success! You added ${
algoliaResponse.objectIDs.length
} records to Algolia search. Object IDs:\n${algoliaResponse.objectIDs.join(
"\n"
)}`
);
} catch (error) {
console.log(error);
}
})();
Now add the following like to your scripts
object in package.json
: "postbuild": "node ./scripts/build-search.js"
. Your scripts object should look like this:
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "node ./scripts/build-search.js",
"start": "next start",
"lint": "next lint"
}
Your script will now run automatically on every build.
Now, let's make sure everything is working as expected. Run node ./scripts/build-search.js
. If you get a success message in your terminal, it means your Contentful data has been saved to Algolia and is ready to be searched.
Creating the Layout
It's time to move on the the frontend of our app. Open pages/index.js and replace the file's contents with the code below.
import algoliasearch from "algoliasearch/lite";
import { InstantSearch } from "react-instantsearch-dom";
//We'll create this file in the next step
import SearchBox from "../components/SearchBox";
const searchClient = algoliasearch(
process.env.NEXT_PUBLIC_ALGOLIA_APP_ID,
process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY
);
const Search = () => {
return (
<>
{/* replace "full_text_search" with the name you used when
indexing your data in scripts/build-search.js*/}
<InstantSearch searchClient={searchClient} indexName="full_text_seach">
{/* We'll create the search box in the next step */}
<SearchBox />
</InstantSearch>
</>
);
};
export default Search;
Creating the Search Box
At the root of your project, create a components
folder. Create SearchBox.js
in your new folder and add the following code to it:
import { useState } from "react";
import { connectSearchBox } from "react-instantsearch-dom";
// We'll create Hits in the next step
import Hits from "./Hits";
function SearchBox({ refine }) {
const [searchTerm, setSearchTerm] = useState("");
// search for matches when even the input field values changes
const handleChange = (e) => {
setSearchTerm(e.target.value);
refine(e.currentTarget.value);
};
return (
<div>
<input
value={searchTerm}
placeholder="Search Blog Posts"
onChange={handleChange}
/>
{/* We'll create Hits in the next step */}
<Hits />
</div>
);
}
export default connectSearchBox(SearchBox);
Diplaying the Results
Create a new file, Hits.js, in the components folder:
import { connectStateResults } from "react-instantsearch-dom";
const ResultsHits = ({ searchState, searchResults }) => {
//The user will need to enter at least two characters
//before they can see search results. Feel free to
//change this number.
const validQuery = searchState.query?.length >= 2;
return (
<>
{/* Show if the user enters less than two characters */}
{!validQuery && <p>Please enter at least 2 characters</p>}
{/* Show if no results match the user's input */}
{searchResults?.hits.length === 0 && validQuery && (
<p>No matching results, sorry.</p>
)}
{/* Show results */}
{searchResults?.hits.length > 0 && validQuery && (
<div style={{ padding: "50px 0 0 0 " }}>
{console.log(searchResults.hits)}
{searchResults.hits.map((hit) => (
// display your hits in any way you like here
<p>{hit.title}</p>
))}
</div>
)}
</>
);
};
export default connectStateResults(ResultsHits);
Wrapping Up
Hopefully you now have a working search box that displays results in real-time as you type your search query. Depending on your use case, you could play with displaying the search results directly below the search box (Google style), or showing them on a separate results page. You can also customize you visiter's search experience in your Algolia dashboard. If you have any questions, leave a comment and I'll do my best to answer.
Happy Coding!