Loading...
Nextjs14-Algolia-Native
Author Cloudapp
E.G.

Next.js 14 -Advanced Search Integration with Algolia UI Libraries (Widgets)

August 7, 2024
Table of Contents

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.

Datasync Contentful - 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-nextjs

New 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>
  );
}
"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>
  );
}

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.

MyWebsite-Results-page
MyWebsite-Results-page

Cloudapp-dev, and before you leave us

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

Please consider clapping and following the writer! 👏 on our Medium Account

Or follow us on twitter -> Cloudapp.dev

Related articles