web

Next.js × algoliaで検索機能を実装する

公開日:2022年1月23日
最終更新日:2022年1月23日
目次

このブログのAPIはmicroCMSを利用していて、microCMSには検索のAPIが用意されていますが、algoliaを使って検索機能を実装してみました。
個人のブログでは検索機能があったとしても、来訪者はほとんど使わないはずなので、記事を削除したりURLが変わって404になった時のために、404ページにalgoliaによる検索機能をつけました。
https://www.mismith.me/404

やること

  • algoliaにビルド時にAPIで記事データを登録
  • algoliaで用意されている検索フォーム、検索結果コンポーネントをカスタマイズ
  • 検索結果数制限
  • 初期表示は何も検索結果を出さない


algoliaに登録

まずは、algoliaのアカウントを取得します。登録はGoogleアカウントなどでできるので特に問題ないと思います。
https://www.algolia.com/

登録が完了したら、Indexを作っておきます。Indexにブログのデータを登録する方法は手動でやったり、APIで登録したり、いくつか方法がありますが、今回はAPIで登録します。

必要な情報

管理画面左下にある「Settings(歯車アイコン)」→「API keys」を開く。

  • Application ID
  • Search-Only API Key
  • Admin API Key


.env ファイルに上記の情報を書いておきます。本番にデプロイする時のためにVercelなどに環境変数の登録を忘れないようにしましょう。

NEXT_PUBLIC_ALGOLIA_APPLICATION_ID={Application ID}
ALGOLIA_ADMIN_API_KEY={Admin API Key}
NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY={Search-Only API Key}


インデックスへ情報を送る


まずは必要なパッケージを追加しておきます。algoliasearch はalgoliaのパッケージ、remove-markdown はalgoliaへ記事データを送る時にプレーンテキストにするためのものです。 @types/remove-markdown はその型定義ファイル。

yarn add remove-markdown @types/remove-markdown algoliasearch 


インデックスへ送る情報を作る

以下のコードを書きます。このブログの構成の場合は libs/algolia.ts に書いています。
getAllContentsについてはこちらの記事を見てください。
microCMSで全記事取得する

import removeMd from 'remove-markdown'
import algoliasearch from 'algoliasearch'
import { getAllContents } from '@/libs/getAllContents'
import { Blog } from '@/types/Blog'

export const generateIndex = async (): Promise<void> => {
  const applicationId = process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID ?? ''
  const adminApiKey = process.env.ALGOLIA_ADMIN_API_KEY ?? ''
  const posts = await getAllContents()

  const objects = posts.map((post: Blog) => {
    return {
      objectID: post.id,
      url: `https://www.mismith.me/blog/${post.category.id}/${post.id}`,
      title: post.title,
      description: post.description,
      content: removeMd(post.content),
    }
  })

  const client = algoliasearch(applicationId, adminApiKey)
  const index = client.initIndex('mismith')
  await index.saveObjects(objects, { autoGenerateObjectIDIfNotExist: true })
}


インデックスへ情報を送る

記事一覧ページの getStaticPropsgenerateIndex を呼び出します。
ローカル環境でいいので、記事一覧ページへアクセスし、algoliaの管理画面のインデックスを見ると記事のデータが登録されているはずです。

export const getStaticProps: GetStaticProps = async () => {
  // 省略
  await generateIndex()
  // 省略
}


このままだとテスト環境で開発する時に記事一覧ページにアクセスすると毎回送信されてしまうので、 libs/algolia.ts の最後を以下のように書き換えておきます。 getStaticProps に記述しているので、ビルド時に送信するようになります。

process.env.NODE_ENV === 'production' &&
    (await index.saveObjects(objects, { autoGenerateObjectIDIfNotExist: true }))


検索フォーム、検索結果一覧を表示

algoliaで用意されているUI Component用のパッケージと型定義ファイルを追加します。

yarn add react-instantsearch-dom @types/react-instantsearch-dom


特にスタイルはしてないですが、とりあえず検索されるかを確認したければ、以下のように書くと検索フォームと結果が表示されます。自分のブログでは Search というコンポーネントを作って、ページで呼び出しています。

import { Wrap } from './styles'
import algoliasearch from 'algoliasearch/lite'
import {
  InstantSearch,
  SearchBox,
  Hits,
} from 'react-instantsearch-dom'
import { Props } from './types'

export const Search = ({ className }: Props) => {
  const applicationId = process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID ?? ''
  const searchApiKey = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY ?? ''
  const searchClient = algoliasearch(applicationId, searchApiKey)
  const indexName = 'your index name'

  return (
    <Wrap className={className}>
      <InstantSearch indexName={indexName} searchClient={searchClient}>
        <SearchBox />
        <Hits />
      </InstantSearch>
    </Wrap>
  )
}


検索結果数を制限

Configure を追加し、hitsPerPage を指定することで制限できます。

import { Wrap } from './styles'
import algoliasearch from 'algoliasearch/lite'
import {
  InstantSearch,
  SearchBox,
  Hits,
  Configure,
} from 'react-instantsearch-dom'
import { Props } from './types'

export const Search = ({ className }: Props) => {
  const applicationId = process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID ?? ''
  const searchApiKey = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY ?? ''
  const searchClient = algoliasearch(applicationId, searchApiKey)
  const indexName = 'your index name'

  return (
    <Wrap className={className}>
      <InstantSearch indexName={indexName} searchClient={searchClient}>
        <Configure hitsPerPage={5} />
        <SearchBox />
        <Hits />
      </InstantSearch>
    </Wrap>
  )
}


初期表示で検索結果を表示しない

何もしないと初期表示である程度候補を出してくれますが、まだ何も検索してないので表示したくない場合もあると思います。
その時は MultipleQueriesQuery をimportして以下のように書きます。
何もなかった時の empty を用意しておきます。

import { Wrap } from './styles'
import algoliasearch from 'algoliasearch/lite'
// 以下追加
import { MultipleQueriesQuery } from '@algolia/client-search'
import {
  InstantSearch,
  SearchBox,
  Hits,
  Configure,
} from 'react-instantsearch-dom'
import { Props } from './types'

export const Search = ({ className }: Props) => {
  // 以下書き換え
  const algoliaClient = algoliasearch(
    process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID || '',
    process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY || ''
  )
  const indexName = 'your index name'

  const empty = {
    hits: [],
    nbHits: 0,
    nbPages: 0,
    page: 0,
    processingTimeMS: 0,
  }

  const searchClient = {
    ...algoliaClient,
    search(requests: MultipleQueriesQuery[]) {
      if (requests.every(({ params }) => !params?.query)) {
        return Promise.resolve(empty)
      }
      return algoliaClient.search(requests)
    },
  }

  return (
    <Wrap className={className}>
      <InstantSearch indexName={indexName} searchClient={searchClient}>
        <Configure hitsPerPage={5} />
        <SearchBox />
        <Hits />
      </InstantSearch>
    </Wrap>
  )
}


UI Componentをカスタマイズする

UI Componentsの使い方やカスタマイズはReactやVueなどフレームワークごとにドキュメントがあるので、それを読めばだいたいわかります。
https://www.algolia.com/doc/api-reference/widgets/react/

以下の3つをカスタマイズします。基本的にはどれも自分でスタイルを当てたコンポーネントを connect~ でラップして使います。それぞれのコンポーネントの記述例を書いた後に、カスタマイズしたコンポーネントを呼び出してみます。

  • SearchBox(検索フォーム)
  • Hits(検索結果)
  • PoweredBy(無料プランでは表示必須)


SearchBox

input にスタイルを当てて以下のように書きました。
https://www.algolia.com/doc/api-reference/widgets/search-box/react/

import { Wrap, StyledInputText } from './styles'

type Props = {
  className?: string
  currentRefinement: string
  refine: (value: string) => void
}

export const SearchInput = ({
  className,
  currentRefinement,
  refine,
}: Props) => {
  return (
    <Wrap className={className}>
      <StyledInputText
        type="search"
        value={currentRefinement}
        onChange={(event) => refine(event.currentTarget.value)}
        placeholder="Next.js, microCMS, Firebase..., etc..."
      />
    </Wrap>
  )
}


Hits

検索結果は以下のように書きました。
https://www.algolia.com/doc/api-reference/widgets/hits/react/#full-example

import Link from 'next/link'
import { Hit } from 'react-instantsearch-core'
import { connectPoweredBy } from 'react-instantsearch-dom'
import {
  Wrap,
  List,
  Item,
  StyledLink,
  Description,
  EmptyContent,
  EmptyContentText,
} from './styles'

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

type Props = {
  readonly className?: string
  readonly hits: Hit<HitDoc>[]
}

export const SearchResults = ({ className, hits }: Props) => {
  return (
    <Wrap className={className}>
      {hits.length > 0 ? (
        <List>
          {hits.map((hit: HitDoc) => (
            <Item key={hit.objectID}>
              <Link href={hit.url} passHref>
                <StyledLink>{hit.title}</StyledLink>
              </Link>
              <Description>{hit.description}</Description>
            </Item>
          ))}
        </List>
      ) : (
        <EmptyContent>
          <EmptyContentText>検索結果がありません。</EmptyContentText>
        </EmptyContent>
      )}
    </Wrap>
  )
}


PoweredBy

PoseredByはそのまま使うとaタグで Warning: Prop "href" did not match. Server: というエラーがでるので、カスタマイズしました。
https://www.algolia.com/doc/api-reference/widgets/powered-by/react/#examples

import Link from 'next/link'
import { Wrap, StyledLink, StyledSvg } from './styles'
import { Props } from './types'

type Props = {
  readonly className?: string
  readonly url: string
}

export const PoweredBy = ({ className, url }: Props) => {
  return (
    <Wrap className={className}>
      powerd by
      <Link
        href={url}
        passHref
      >
        <StyledLink target="_blank">
          <StyledSvg
            width="512px"
            height="127px"
            viewBox="0 0 512 127"
            version="1.1"
            xmlns="http://www.w3.org/2000/svg"
            preserveAspectRatio="xMidYMid"
          >
            ...
          </StyledSvg>
        </StyledLink>
      </Link>
    </Wrap>
  )
}


ロゴのSVGはSVG Pornからダウンロードしました。
https://svgporn.com/

カスタマイズしたコンポーネントの呼び出し

以下のように Search コンポーネントを書き換えて、呼び出します。
具体的には検索フォームコンポーネントはconnectSearchBox で、検索結果コンポーネントは connectHits でラップします。
あとはそれをデフォルトのコンポーネントと同じように配置するだけ。

import {
  Wrap,
  Title,
  Text,
  StyledSearchInput,
  StyledSearchResults,
} from './styles'
import algoliasearch from 'algoliasearch/lite'
import { MultipleQueriesQuery } from '@algolia/client-search'
import {
  InstantSearch,
  Configure,
  connectSearchBox,
  connectHits,
} from 'react-instantsearch-dom'
import { Props } from './types'

export const Search = ({ className }: Props) => {
  // ここ2行追記
  const CustomSearchBox = connectSearchBox(StyledSearchInput)
  const CustomHits = connectHits(StyledSearchResults)
  const algoliaClient = algoliasearch(
    process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID || '',
    process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY || ''
  )
  const indexName = 'your index name'

  const empty = {
    hits: [],
    nbHits: 0,
    nbPages: 0,
    page: 0,
    processingTimeMS: 0,
  }

  const searchClient = {
    ...algoliaClient,
    search(requests: MultipleQueriesQuery[]) {
      if (requests.every(({ params }) => !params?.query)) {
        return Promise.resolve(empty)
      }
      return algoliaClient.search(requests)
    },
  }

  return (
    <Wrap className={className}>
      <InstantSearch indexName={indexName} searchClient={searchClient}>
        <Configure hitsPerPage={5} />
        <CustomSearchBox />
        <CustomHits />
      </InstantSearch>
    </Wrap>
  )
}


poweredBy はCustomHitsの中に含めています。

export const SearchResults = ({ className, hits }: Props) => {
  const CustomPoweredBy = connectPoweredBy(StyledPoweredBy)

  return (
    <Wrap className={className}>
      {hits.length > 0 ? (
        <List>
          {hits.map((hit: HitDoc) => (
            <Item key={hit.objectID}>
              <Link href={hit.url} passHref>
                <StyledLink>{hit.title}</StyledLink>
              </Link>
              <Description>{hit.description}</Description>
            </Item>
          ))}
        </List>
      ) : (
        <EmptyContent>
          <EmptyContentText>検索結果がありません。</EmptyContentText>
        </EmptyContent>
      )}
      <CustomPoweredBy />
    </Wrap>
  )
}


さいごに

これで検索機能を追加できました。カスタマイズで時間かかりましたが、実装自体は思ったより簡単にできました。
個人で使う分には無料枠で十分ですし、すごく便利です。

About the author

大阪でフロントエンドエンジニアをしています。写真を撮るのが趣味です。よかったら500pxに載せてる写真も見てください。
web上に公開しているので、正確さに可能な限り努力してますが、個人の備忘録程度に書いてるので、ご自身の判断で参考程度に読んでください。
間違いやご意見があれば、コンタクトやSNSに気軽にご連絡ください。

Read next

Category

  • web
  • 雑記