Table of Contents
- Used Stack
- NPM Packages for InstantSearch
- New Page “SearchAlgolia”
- New Component Algoliasearch
- New and extended Card Component for Search Results
- Restyle Hits Algolia UI Widget
- Restyle RefinementList Algolia UI Widget
- Global.css with Custom Classes
- Changing Middleware.ts file for Searchparams handling
- Changes in search.component.tsx
- New Algolia Search Page
- Cloudapp-dev, and before you leave us
In this follow-up story, I will show you how to easily integrate the Algolia Instant search into your existing Next.js 14 project. We will also use custom styling with TailwindCss.
Here is the GitHub repo with the entire code. Below, you will find the link to the example page.
Example page hosted on Vercel -> https://nextjs14-advanced-algoliasearch.vercel.app/
In the story below, I showed you how to integrate Algolia into your Nextjs 14 project. Here, I used the REST API of Algolia, but now we use the native UI Library (Widgets) to directly integrate the lightning-fast data service.
Easy Integration as On-Site Search
Please also consider this story, where I explain how you can sync your data in Contentful with Algolia.
Used Stack
I will start with my default stack:
Next.js 14 as the web framework, and I will use the provided middleware edge function
TailwindCss for Styling
Contentful CMS (Free Plan)
Algolia as Search-Engine
Vercel for hosting
NPM Packages for InstantSearch
If you read the first story, you have already installed the needed NPM packages
npm i algoliasearch instantsearch.css react-instantsearch react-instantsearch-nextjs react-instantsearch-router-nextjsNew Page “SearchAlgolia”
Under src/app/[locale]/ we already added the new page “searchalgolia” for the integration of the UI Widgets. The structure of this page is quite easy because we only import and show the algoliasearch component, which we will create in the next step.
export const dynamic = "force-dynamic";
import Search from "@/components/search/algoliasearch.component";
export default function Page() {
return <Search />;
}New Component Algoliasearch
Here comes the magic. We import all the necessary components to handle the search request and show Algolia Features like the Search box, the RefinementList, and the DynamicWidgets. Snippets and Highlights will be shown in an upcoming story.
I would like to point out the component “CardAlgolia”, which we gonna use to show the search results below the search box.
"use client";
import algoliasearch from "algoliasearch/lite";
import { Hit as AlgoliaHit } from "instantsearch.js";
import {
Hits,
Highlight,
SearchBox,
RefinementList,
DynamicWidgets,
Snippet,
} from "react-instantsearch";
import { PageBlogPostFieldsFragment } from "@/lib/__generated/sdk";
import { InstantSearchNext } from "react-instantsearch-nextjs";
import { Panel } from "@/components/search/panel.component";
import { useParams, useSearchParams } from "next/navigation";
import type { LocaleTypes } from "@/app/i18n/settings";
import CardAlgolia from "./cardalgolia.component";
interface ArticleAuthorProps {
article: PageBlogPostFieldsFragment;
}
const client = algoliasearch(
process.env.NEXT_PUBLIC_ALGOLIA_APP_ID || "",
process.env.NEXT_PUBLIC_ALGOLIA_API_KEY || ""
);
type CardProps = {
hit: AlgoliaHit<{
intName: string;
image: string;
pubdate: Date;
slug: string;
width: number;
height: number;
tags: string[];
lang: {
"de-DE": { content: string; shortDescription: string; title: string };
"en-US": { content: string; shortDescription: string; title: string };
};
}>;
};
let locale: LocaleTypes;
function Hit({ hit }: CardProps) {
const blurURL = new URL(hit.image);
blurURL.searchParams.set("w", "10");
return (
<div>
<CardAlgolia key={hit.objectID} result={hit} />
</div>
);
}
export default function Search() {
const urlSearchParams = useSearchParams();
const params = Object.fromEntries(urlSearchParams.entries());
console.log("params_searchompo", params);
locale = useParams()?.locale as LocaleTypes;
return (
<InstantSearchNext
searchClient={client}
indexName={process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME}
routing={{
router: {
cleanUrlOnDispose: false,
windowTitle(routeState) {
const indexState = routeState.indexName || {};
return indexState.query
? `MyWebsite - Results for: ${indexState.query}`
: "MyWebsite - Results page";
},
},
}}
future={{
preserveSharedStateOnUnmount: true,
}}
>
<div className="Container mx-4">
<div>
<DynamicWidgets fallbackComponent={FallbackComponent} />
</div>
<div>
<SearchBox className="p-3 shadow-sm" />
<Hits
hitComponent={Hit}
classNames={{
root: "MyCustomHits",
}}
/>
</div>
</div>
</InstantSearchNext>
);
}
function FallbackComponent({ attribute }: { attribute: string }) {
return (
<Panel header={attribute}>
<RefinementList
classNames={{
root: "MyCustomRefinementList",
}}
attribute={attribute}
limit={8}
showMore={true}
showMoreLimit={20}
/>
</Panel>
);
}New and extended Card Component for Search Results
"use client";
import Image from "next/image";
import Link from "next/link";
import { FormatDate } from "@/components/contentful/format-date/FormatDate";
import { useParams } from "next/navigation";
import type { LocaleTypes } from "@/app/i18n/settings";
import { twMerge } from "tailwind-merge";
import { ArticleLabel } from "@/components/contentful/ArticleLabel";
interface CardProps {
result: {
intName: string;
image: string;
pubdate: Date;
slug: string;
width: number;
height: number;
tags: string[];
lang: {
"de-DE": { content: string; shortDescription: string; title: string };
"en-US": { content: string; shortDescription: string; title: string };
};
};
}
interface LangProps {
title: string;
content: string;
shortDescription: string;
}
export default function CardAlgolia({ result }: CardProps) {
const locale = useParams()?.locale as LocaleTypes;
const langNr = locale === "de-DE" ? 0 : 1;
const langresult = JSON.parse(
JSON.stringify(Object.entries(result.lang)[langNr][1])
) as LangProps;
const className = "md:grid-cols-2 lg:grid-cols-3";
const classNameImage = "object-cover aspect-[16/10] w-full";
const blurURL = new URL(result.image);
blurURL.searchParams.set("w", "10");
return (
// {/* group - wird benötigt damit man unten im Classname darauf verweisen kann mit group-hover:.... */}
<div className="flex flex-col">
<div
className={twMerge(
"flex flex-1 flex-col overflow-hidden dark:shadow-white shadow-lg dark:shadow-sm-light",
className
)}
>
<Link href={`/${locale}/${result.slug}`}>
<Image
src={result.image}
width={result.width || 722}
height={result.height || 590}
sizes="(max-width: 1200px) 100vw, 50vw"
placeholder="blur"
blurDataURL={blurURL.toString()}
alt={langresult.title || ""}
className={twMerge(classNameImage, "transition-all")}
></Image>
</Link>
<div className="flex flex-col flex-1 px-4 py-3 dark:bg-gray-800 md:px-5 md:py-4 lg:px-7 lg:py-5">
{langresult.title && (
<Link href={`/${locale}/${result.slug}`}>
<p className="mb-2 h3 line-clamp-2 text-gray-800 dark:text-[#AEC1CC] md:mb-3">
{langresult.title}
</p>
</Link>
)}
{langresult.shortDescription && (
<p className="mt-2 text-base line-clamp-2">
{langresult.shortDescription}
</p>
)}
<div className="flex flex-wrap max-w-2xl gap-2 mr-auto">
{result.tags.map((tag: string, index) => (
<Link href={`/${locale}/search/${tag}`} key={index}>
<ArticleLabel className="flex items-center ml-1">
{tag}
</ArticleLabel>
</Link>
))}
<div className={twMerge("ml-auto pl-2 text-xs text-gray-600")}>
<FormatDate date={result.pubdate} />
</div>
</div>
</div>
</div>
</div>
);
}Restyle Hits Algolia UI Widget
with classnames={{root:”MyCustomHits”,}} we add a custom class to the Widget, which we can address in our global.css file.
<Hits
hitComponent={Hit}
classNames={{
root: "MyCustomHits",
}}
/>Restyle RefinementList Algolia UI Widget
Same approach here with classNames={{root: “MyCustomRefinementList”,}}. With the parameter attribute we define the attribute, which we would like for the refinement. I opted for the “tag” in the Algolia backend configuration.
<RefinementList
classNames={{
root: "MyCustomRefinementList",
}}
attribute={attribute}
limit={8}
showMore={true}
showMoreLimit={20}
/>Global.css with Custom Classes
Here we address the newly added Classes in the global.css
.MyCustomHits ol {
@apply grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-y-4 gap-x-5 lg:gap-x-12 lg:gap-y-12;
}
.MyCustomHits li {
@apply dark:bg-gray-900 bg-white p-3 text-sm dark:text-[#FAFAFA] text-gray-600 md:text-sm;
}
.MyCustomRefinementList li {
@apply dark:bg-gray-900 bg-white ml-3 py-1 text-base dark:text-[#FAFAFA] text-gray-600;
}Changing Middleware.ts file for Searchparams handling
Below is my complete middleware.ts file
import { NextResponse } from "next/server";
import type { NextFetchEvent, NextRequest } from "next/server";
import { fallbackLng, locales } from "@/app/i18n/settings";
export async function middleware(request: NextRequest, event: NextFetchEvent) {
const { pathname, search } = request.nextUrl;
//Entfernt den FallbackLng aus dem Pathname, sofern dieser vorhanden ist
if (
pathname.startsWith(`/${fallbackLng}/`) ||
pathname === `/${fallbackLng}`
) {
return NextResponse.redirect(
new URL(
pathname.replace(
`/${fallbackLng}`,
pathname === `/${fallbackLng}` ? "/" : ""
),
request.url
),
301
);
}
// Check if the pathname is missing any locale
const pathnameIsMissingLocale = locales.every(
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
);
// Füght den FallbackLng vor den Pathname ein, sofern keine Sprache im Pathname vorhanden ist
if (pathnameIsMissingLocale) {
const RewriteUrl = request.nextUrl;
RewriteUrl.pathname = `/${fallbackLng}${pathname}`;
return NextResponse.rewrite(new URL(RewriteUrl, request.url));
}
}
export const config = {
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
matcher: [
"/((?!api|sitemap.xml|robots.txt|_next/static|_next/image|favicons|images|favicon.ico).*)",
],
};Changes in search.component.tsx
We are almost done; now we have to modify the search.component.tsx, which we integrated into the header component and which pushes the search term to the right Algoliapage to perform the search.
"use client";
import { useState, useEffect } from "react";
import { useRouter, usePathname, useParams } from "next/navigation";
import type { LocaleTypes } from "@/app/i18n/settings";
import { fallbackLng } 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 !== "");
// 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; // if there is no search, return
const searchParams = `example_dev[query]`;
if (locale === fallbackLng) {
router.push(
`/searchalgolia?${encodeURIComponent(searchParams)}=${search}`
);
} else {
router.push(
`/${locale}/searchalgolia?${encodeURIComponent(searchParams)}=${search}`
);
}
}
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>
);
}New Algolia Search Page
And voilá, now we have a new, custom-styled search page powered by the Algolia UI Library. In the next story we will add new widgets like snippets, highlights, etc.

Cloudapp-dev, and before you leave us
Thank you for reading until the end. Before you go:


