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

Next.js 14 -Algolia search with native HighLight and snippet components

August 11, 2024
Table of Contents

In the last story, we used the native Algolia Widgets for React (Hits, SearchBox, RefinementList, and DynamicWidgets). Now we will do a deep dive into the last two (“HighLight” and “Snippet”), which we did not use in the first step.

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/

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

RefinementList Widget

I forgot one detail in the last story (linked above). The parameter attribute={attribute} defines the used attribute. In our case we used “tags”. limit defines the max number of visualized refinement entries (tags). showmore defines if we want show or not the “ShowMore” Button if there are more entries as we specified in limit and showMoreLimit defines the max number of refinement entries that will be visualized.

//src/components/search/algoliasearch.component.tsx
function FallbackComponent({ attribute }: { attribute: string }) {
  return (
    <Panel header={attribute}>
      <RefinementList
        classNames={{
          root: "MyCustomRefinementList",
        }}
        attribute={attribute}
        limit={8}
        showMore={true}
        showMoreLimit={20}
      />
    </Panel>
  );
}

NPM Packages

We don’t need any new package since we already installed the Npm package “react-instantsearch”, which includes all that we need. Now, we need the highlight and snippet widget from this package.

We can remove both imports from the main Algolia component, leaving only Hits, SearchBox, RefinementList, and DynamicWidgets. The rest of the code is fine.

//src/components/serach/algoliasearch.component.tsx
"use client";

import algoliasearch from "algoliasearch/lite";
import { Hit as AlgoliaHit } from "instantsearch.js";
import {
  Hits,
  SearchBox,
  RefinementList,
  DynamicWidgets,
} 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());

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

Here, we only change one line so that we can use an Env-Variable for the Index Name.

const searchParams = `${process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME}[query]`;

Complete code after the change

//src/components/header/search.component.tsx
"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 = `${process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME}[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>
  );
}

Integration of HighLight and Snippet in CardAlgolia Component

As a first step, we have to import the Widgets

import { Hit as AlgoliaHit } from "instantsearch.js";
import { Highlight, Snippet } from "react-instantsearch";

then we replace the old Interface declaration with a new Type declaration, where we use “AlgoliaHit”, which was imported as well.

//old
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 };
    };
  };
}

//new
type CardProps = {
  result: 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 };
    };
  }>;
};

With the new type in place, we can integrate our new widgets.

//old 
{langresult.title}
//new
<Highlight attribute="intName" hit={result} />

//old  
{langresult.shortDescription}
//new
<Snippet attribute="intName" hit={result} />

Complete Code

//src/components/search/cardalgolia.component.tsx
"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";
import { Hit as AlgoliaHit } from "instantsearch.js";
import { Highlight, Snippet } from "react-instantsearch";

type CardProps = {
  result: 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 };
    };
  }>;
};

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} */}
                <Highlight attribute="intName" hit={result} />
              </p>
            </Link>
          )}

          {langresult.shortDescription && (
            <p className="mt-2 text-base line-clamp-2">
              {/* {langresult.shortDescription} */}
              <Snippet attribute="intName" hit={result} />
            </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>
  );
}

Algolia Configuration

We have to adapt the Algolia Index Configuration so that the widgets show the data.

Highlight Attributes

Here, we can go with the default setup so that all attributes marked as searchable are highlighted.

Index-Algolia-highlight
Index-Algolia-highlight

Snippet Attributes

Select the attribute for the snippet. ShortDescription or Description are default values that you can or should use.

Index-Algolia-snippet
Index-Algolia-snippet

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