Algolia On-Site-Search Integration with Next.js 14

Algolia and Next.js 14 — Easy Integration as On-Site-Search at no cost

Every Blog needs a lightning-fast search and content filtering. Let’s use one of the best services in the market and combine it with a nice-looking Tag Cloud delivered in Milliseconds

--

You start a blog or marketing website, and over time, the content grows, and the project becomes successful. You are probably lucky that one of your posts goes viral and a lot of traffic hits your page. Your visitors want to know more about your project and valuable content.

It’s time for a solution that is easy to integrate and lightning-fast so everybody can find what it’s searching for.

In my previous post, I provided a complete how-to that guides you through the synchronization process from Contentful to Algolia. Aloglia offers 10k Search Requests/Month and 1m records on the free plan which is a lot for new projects. If these limits are insufficient and you need more, you can choose the pay-as-you-go solution or something different.

Here you can already spoiler the final page -> https://nextjs14-algolia-search.vercel.app/

In the previous post, we already added our new Algolia Env-Variables to .env.local

NEXT_PUBLIC_ALGOLIA_APP_ID=xxxx -> Algolia ApplicationID 
NEXT_PUBLIC_ALGOLIA_API_KEY=xxxx -> Algolia Search-Only Key used later
ALGOLIA_MASTER_KEY=xxxx -> Algolia Admin Key (Write rights)
NEXT_PUBLIC_ALGOLIA_INDEX_NAME=example_dev
API_KEY=xxxxx -> Random Key, which we use for securing the sync endpoint

Now, we go ahead with the installation of new NPM packages

npm i algoliasearch instantsearch.css react-instantsearch react-instantsearch-nextjs react-instantsearch-router-nextjs

We also did some work on TailwindCss and removed the NPM package “@contentful/f36-tokens” because, since version 3 of TailwindCSS, a rich palette of default colors has already been included in the main package.

Here is the complete overview regarding the default color palette from the Tailwind website.

We already installed the contentful NPM package “@contentful/rich-text-plain-text-renderer” because we needed it for the content sync between Contentful and Algolia, as shown in the previous post.

Now, we will adapt the layout.tsx file under src/app/[locale]. Below is the new code. We added “instantsearch.css/themes/satellite-min.css” in the import section, and we will pass a second prop, “showBar={true},” to the header component.

<Header showBar={true} menuItems={headerdata} />
import type { Metadata } from "next";
import { Urbanist } from "next/font/google";
import { draftMode } from "next/headers";
import "instantsearch.css/themes/satellite-min.css";
import "@/app/globals.css";
import Header from "@/components/header/header.component";
import Footer from "@/components/footer/footer.component";
import { Providers } from "@/components/header/providers";
import getAllNavitemsForHome from "@/components/header/navbar.menuitems.component";
import getAllFooteritemsForHome from "@/components/footer/footer.menuitems.component";
import ExitDraftModeLink from "@/components/header/draftmode/ExitDraftModeLink.component";
import { locales } from "@/app/i18n/settings";

const urbanist = Urbanist({ subsets: ["latin"], variable: "--font-urbanist" });

export async function generateStaticParams() {
return locales.map((lng) => ({ lng }));
}

export const metadata: Metadata = {
title: "Example Blog",
description: "Your Example Blog Description",
icons: {
icon: [
{ rel: "icon", url: "/favicons/favicon-16x16.png", sizes: "16x16" },
new URL("/favicons/favicon-16x16.png", process.env.NEXT_PUBLIC_BASE_URL),
{ rel: "icon", url: "/favicons/favicon-32x32.png", sizes: "32x32" },
new URL("/favicons/favicon-32x32.png", process.env.NEXT_PUBLIC_BASE_URL),
],
shortcut: [{ rel: "shortcut icon", url: "/favicons/favicon.ico" }],
apple: [
{
url: "/favicons/apple-touch-icon.png",
sizes: "180x180",
type: "image/png",
},
],
},
};

type LayoutProps = {
children: React.ReactNode;
params: { locale: string };
};

export default async function RootLayout({ children, params }: LayoutProps) {
const locale = params.locale;
const htmlLang = locale === "en-US" ? "en" : "de";
const headerdata = await getAllNavitemsForHome(locale);
const footerdata = await getAllFooteritemsForHome(locale);

return (
<html lang={htmlLang} suppressHydrationWarning>
<head></head>
<body>
<main className={`${urbanist.variable} font-sans dark:bg-gray-900`}>
<Providers>
<Header showBar={true} menuItems={headerdata} />
{draftMode().isEnabled && (
<p className="bg-emerald-400 py-4 px-[6vw]">
Draft mode is on! <ExitDraftModeLink className="underline" />
</p>
)}
{children}
<Footer footerItems={footerdata} />
</Providers>
</main>
</body>
</html>
);
}

Before adopting the header component, we must add our new search component (Search bar), “search.component.tsx,” in our components subfolder “header.”

"use client";

import { useState, useEffect } from "react";
import { useRouter, usePathname, useParams } from "next/navigation";
import type { LocaleTypes } from "@/app/i18n/settings";
import { useTranslation } from "@/app/i18n/client";

export default function SearchBar({
searchCta,
searchPlaceholder,
}: {
searchCta: string;
searchPlaceholder: string;
}) {
const [search, setSearch] = useState("");
const router = useRouter();
const path = usePathname();
const locale = useParams()?.locale as LocaleTypes;
const { t } = useTranslation(locale, "common");

const pathWithoutQuery = path.split("?")[0];
let pathArray = pathWithoutQuery.split("/");
pathArray.shift();
pathArray = pathArray.filter((path) => path !== "");

// console.log("path", pathArray[0]);

// if the path is searchalgolia, don't show the search bar
if (pathArray[0] === "searchalgolia") {
return null;
}

function handleSubmit(e: any) {
e.preventDefault(); // prevent page refresh
if (!search) return; //
router.push(`/${locale}/search/${search}`); // push to the search page
}

return (
<div className="mb-1 bg-gray-100 rounded-sm shadow-md dark:bg-gray-800">
<form onSubmit={handleSubmit}>
<label
htmlFor="default-search"
className="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white"
>
{t("search.button")}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg
className="w-4 h-4 text-gray-500 dark:text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 20"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"
/>
</svg>
</div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
// type="text"
type="search"
id="default-search"
className="block w-full p-4 pl-10 text-base text-gray-900 border border-gray-300 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder={t("search.searchPlaceholder")}
// {searchPlaceholder ? searchPlaceholder : "Search keywords..."}
required
/>
<button
disabled={!search} // disable the button if there is no search
type="submit"
className="text-white absolute right-2.5 bottom-2.5 bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-base px-4 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
{t("search.button")}
{/* {searchCta ? searchCta : Search} */}
</button>
</div>
</form>
</div>
);
}

As always, you will find the complete code in my Github-Repo -> https://github.com/cloudapp-dev/nextjs14-SEO/tree/nextjs14-part7

In the header component, we add a new import and a new interface

import SearchBar from "./search.component";
interface HeaderProps {
showBar: boolean;
menuItems: any;
}

and we add the new “search” component.

      {showBar && (
<SearchBar
searchCta="Search"
searchPlaceholder="Search example.dev..."
/>
)}

We will add these components to our new search pages, so we must create our new search components in “src/components/search,” which represents a new folder under components.

# Using Components provided by Algolia
algoliasearch.component.tsx
panel.component.tsx
# Used for our custom solution
card.component.tsx
results.component.tsx
tagcloudsimple.component.tsx

and we have to add new translations in the common.json files for the multi-language-handling under src/app/i18n/locales/de-DE/

  "search": {
"button": "Suche",
"searchResults": "Suchergebnisse",
"searchResultsFor": "Suchergebnisse für",
"noResultsFound": "Keine Ergebnisse gefunden",
"resultsFoundIn": "Ergebnisse gefunden in",
"searchPlaceholder": "Suchen auf Example.dev...",
"dashboardsearchplaceholder": "Suchen nach Benutzern/Rollen auf ...",
"dashboardsearchhighline": "Benutzer",
"dashboardsearchdescription": "Suchen Sie nach Benutzern, um deren Profile anzuzeigen und zu verwalten.",
"searchdescription": "Nachfolgend finden Sie alle verfügbaren Tags und Ihre Suchergebnisse auf ..."
}

and for “en-US”

"search": {
"button": "Search",
"searchResults": "Search Results",
"searchResultsFor": "Search Results for",
"noResultsFound": "No Results found",
"resultsFoundIn": "Results found for",
"searchPlaceholder": "Search Example.dev...",
"dashboardsearchplaceholder": "Search for User/Roles on ...",
"dashboardsearchhighline": "Users",
"dashboardsearchdescription": "Search for Users for Profile edit",
"searchdescription": "Below you will find all available Tags and your search results on ..."
}

We also need a new route because we must fetch the tags from the Algolia Index to show them in the “tag cloud” on the search result page.

Therefore, let’s create a new route under src/app/api/search/facets/route.ts

import { NextResponse } from "next/server";
import algoliasearch from "algoliasearch/lite";

const client = algoliasearch(
process.env.NEXT_PUBLIC_ALGOLIA_APP_ID || "",
process.env.NEXT_PUBLIC_ALGOLIA_API_KEY || ""
);

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
let slug: string = "*";
if (searchParams.has("slug")) {
slug = searchParams.get("slug") || "*";
}

const index = client.initIndex(
`${process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME}`
);
const datanew: any = [];
const datanew2: any = [];
let minSize = 0;
let maxSize = 0;

await index
.search(slug, {
facets: ["*"],
hitsPerPage: 50,
})
.then(({ facets }) => {
if (!facets) {
return NextResponse.json([]);
}

const tags = facets["tags"];

for (let x in tags) {
datanew.push({
value: x,
count: tags[x],
});
datanew2.push(tags[x]);
}
datanew2.sort();
minSize = datanew2[0];
maxSize = datanew2[datanew2.length - 1];
// console.log("datanew", datanew);
// return NextResponse.json(datanew);
});

return NextResponse.json({ datanew, minSize, maxSize });
}

Since we need a new ArticleLabel Component within the new card.component.tsx on the search result page, we have to add this component under src/components/contentful/ArticleLabel.tsx as well.

import { HTMLProps, ReactNode } from "react";
import { twMerge } from "tailwind-merge";

interface ArticleLabelProps extends HTMLProps<HTMLSpanElement> {
children: ReactNode;
}

export const ArticleLabel = ({
children,
className,
...props
}: ArticleLabelProps) => {
return (
<span
className={twMerge(
"rounded bg-purple-200 px-2 py-1 text-2xs font-semibold uppercase leading-none tracking-widest text-purple-600",
className
)}
{...props}
>
{children}
</span>
);
};
ArticleLabel in Card Component

Screenshot, which shows the Tag/Tags in the TagCloud

Tags in TagCloud

As a last step, we add the new “search” and “searchalgolia” pages under src/app/[locale]/search/[searchTerm]

export const dynamic = "force-dynamic";

import Results from "@/components/search/results.component";
import { Container } from "@/components/contentful/container/Container";
import { createTranslation } from "@/app/i18n/server";
import { LocaleTypes } from "@/app/i18n/settings";
import TagCloudSimple from "@/components/search/tagcloudsimple.component";

interface SearchPageParams {
searchTerm: string;
locale: string;
}

interface SearchPageProps {
params: SearchPageParams;
}

async function SearchPage({ params }: SearchPageProps) {
const res = await fetch(
`https://${process.env.NEXT_PUBLIC_ALGOLIA_APP_ID}-dsn.algolia.net/1/indexes/${process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME}?query=${params.searchTerm}&attributesToHighlight=[]&attributesToRetrieve=lang.${params.locale},intName,tags,height,width,image,pubdate,slug`,
{
headers: new Headers({
"X-Algolia-API-Key": process.env.NEXT_PUBLIC_ALGOLIA_API_KEY || "",
"X-Algolia-Application-Id":
process.env.NEXT_PUBLIC_ALGOLIA_APP_ID || "",
}),
}
);

if (!res.ok) {
throw new Error("Failed to fetch data");
}

const searchFacets = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/search/facets`,
{
next: { revalidate: 0 }, // No cache
}
).then((res) => res.json());

const { datanew, minSize, maxSize } = searchFacets;
const data = await res.json();
const results = data.hits;
const { t } = await createTranslation(params.locale as LocaleTypes, "common");

return (
<Container className="my-8 md:mb-10 lg:mb-16">
<h1 className="flex items-center justify-center mb-4">
{params.searchTerm}
</h1>

{/* Tag Cloud Integration */}
<TagCloudSimple
datanew={datanew}
minSize={minSize * 10}
maxSize={maxSize * 5}
locale={params.locale}
/>

<div className="mt-8"></div>

{results && results.length === 0 && (
<h2 className="pt-6 text-center">No results found</h2>
)}
{results && <Results results={results} />}
</Container>
);
}

export default SearchPage;
Search Page

and src/app/[locale]/searchalgolia

import Search from "@/components/search/algoliasearch.component";

export const dynamic = "force-dynamic";

export default function Page() {
return <Search />;
}
Searchalgolia Page

If we want to use attributes for the “Algolia Snippet Component” in algoliasearch.component.tsx

<Snippet hit={hit} attribute="pubdate" />

we have to enable those attributes in the Algolia Index Configuration

Snippet Config Algolia Index

Last, we must enable the facets where we use our tags as a foundation.

Facet Config Algolia Index

Cloudapp-dev

Thank you for reading until the end. Before you go:

  • Please consider clapping and following the writer! 👏
  • Visit cloudapp.dev to learn how we support the dev community worldwide.

--

--

Developer and tech/cloud enthusiast, who believes in Knowledge Sharing - Free Tutorials (https://www.cloudapp.dev)