Next.js + Algoliaで全文検索UIを実装する

公開日時

昨日の記事でContentfulの記事をAlgoliaのインデックスに登録したので、今回はアプリケーション側での実装方法についてまとめる。

このブログはNext.jsで構築しているので、検索UIの実装にはreact-instantsearchを利用した。

Algolia管理画面での設定

前回のおさらいになるが、インデックスは↓として登録している。

{
    url: "記事URL",
    title: "タイトル",
    description: "記事概要",
    content: "MarkdownをPlainTextに変換した記事本文",
    objectID: "Contentfulの記事ID",
}

Indices => Configuration => Searchable attributes で検索対象とする属性を設定する。

今回は titledescription を検索対象とした。

algolia5

また、Indices => Configuration => Language で言語をJapaneseに設定しておいた。

  • Index Languages: Japanese
  • Query Languages: Japanese
algolia6

検索UIの実装

next.js/examples/with-algolia-react-instantsearch を参考にしつつ実装を行った。

  • 必要ライブラリのインストール
yarn add algoliasearch react-instantsearch-dom instantsearch.css
yarn add -D @types/react-instantsearch-dom
  • lib/algolia.ts
import { MultipleQueriesQuery } from '@algolia/client-search'
import algoliasearch from 'algoliasearch/lite'

const algoliaSearchApiKey = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY || ''
const appId = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_APP_ID || ''
const algoliaClient = algoliasearch(appId, algoliaSearchApiKey)

export const indexName = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_INDEX_NAME || ''
export const minQueryLength = 3

export const searchClient = {
  search(requests: MultipleQueriesQuery[]) {
    // コンポーネント表示時に検索を実行しないように
    // 検索文字がminQueryLength未満の場合は実行しないように
    // https://www.algolia.com/doc/guides/building-search-ui/going-further/conditional-requests/react/
    if (
      requests.every(({ params }: MultipleQueriesQuery) => {
        return !params?.query || params.query.length < minQueryLength
      })
    ) {
      return Promise.resolve({
        results: requests.map(() => ({
          hits: [],
          nbHits: 0,
          nbPages: 0,
          page: 0,
          processingTimeMS: 0,
        })),
      })
    }

    return algoliaClient.search(requests)
  },
}

デフォルトのSearchClientだとコンポーネント表示時に空文字列で検索を実行してしまうため、Conditional Requestsを実装した。

ついでに検索文字がminQueryLength未満の場合は実行しないようにしておいた。

  • components/Search.tsx
import { useRouter } from 'next/router'
import React, { useCallback, useEffect, useState } from 'react'
import { Hit } from 'react-instantsearch-core'
import {
  Configure,
  connectSearchBox,
  Highlight,
  Hits,
  InstantSearch,
  PoweredBy,
  SearchBox,
} from 'react-instantsearch-dom'

import { indexName, minQueryLength, searchClient } from '@/lib/algolia'

type HitDoc = {
  url: string
  title: string
  content: string
  description?: string
  objectID: string
}

const HitComponent = ({ hit, onClick }: { hit: Hit<HitDoc>; onClick: () => void }) => {
  const router = useRouter()

  const clickHandler = (url: string) => {
    onClick()
    router.push(url)
  }

  return (
    <a
      href={hit.url}
      className="w-full cursor-pointer"
      onClick={(e) => {
        e.preventDefault()
        clickHandler(hit.url)
      }}
    >
      <div>
        <Highlight className="text-gray-800" attribute="title" hit={hit} />
      </div>
      <div>
        <Highlight className="mt-2 text-gray-500" attribute="description" hit={hit} />
      </div>
    </a>
  )
}

const SearchResult = connectSearchBox(({ refine, currentRefinement }) => {
  const [isShow, shouldShow] = useState(false)

  useEffect(() => {
    shouldShow(!!currentRefinement)
  }, [currentRefinement, shouldShow])

  const handleResetSearchWords = useCallback(() => {
    refine('')
  }, [refine])
  if (!isShow) return null

  return (
    <div className="bg-white rounded-sm">
      <Hits
        hitComponent={({ hit }: { hit: Hit<HitDoc> }) => (
          <HitComponent hit={hit} onClick={handleResetSearchWords} />
        )}
      />
      {currentRefinement.length >= minQueryLength && (
        <div className="flex justify-end p-2 dark:text-gray-800">
          <PoweredBy />
        </div>
      )}
    </div>
  )
})

export const Search = () => {
  return (
    <>
      <InstantSearch indexName={indexName} searchClient={searchClient}>
        <Configure hitsPerPage={50} />
        <div className="flex justify-center md:justify-end">
          <div className="w-full md:w-1/4">
            <SearchBox translations={{ placeholder: 'Search' }} />
          </div>
        </div>
        <div className="mt-1">
          <SearchResult />
        </div>
      </InstantSearch>
    </>
  )
}

Linkでページ遷移を行った場合はページ遷移後も検索結果が表示されたままになってしまうため、下記記事を参考にさせていただきクリック時に検索文字列をリセットすることにした。

また、Algoliaを無料プランで利用する場合はPoweredByの表示が必須とのことだったので、検索結果画面に表示するようにしている。

  • pages/_app.tsx

satellite-min.css をimportして検索機能のcssを設定する。

import 'instantsearch.css/themes/satellite-min.css'
import '@/styles/tailwind.css'
import '@/styles/algolia.css'

Tailwind CSSと組み合わせた際に、↓のようにiOS実機で入力フォームの表示が角丸になる現象が発生した。

ios search

そこで、一部のcssを上書きして調整した。

/* styles/algolia.css */
.ais-SearchBox-form {
  background-color: transparent;
}

.ais-SearchBox-input {
  -webkit-appearance: none;
  box-shadow: none;
}

これでNext.js + Algoliaでの全文検索UIが実装できた。

参考


Related #algolia

Contentfulの記事をAlgoliaのインデックスに登録する

Contetful Webhookの変換Helperが便利