Next.jsサイトにPagefind検索機能を実装する方法

公開日:
目次

静的サイトに高速で軽量な検索機能を追加したい場合、Pagefindは最適な選択肢の一つです。この記事では、Next.jsサイトにPagefind検索機能を実装する手順と、実装時に遭遇する問題の解決方法について詳しく解説します。

Pagefindとは

Pagefindは、静的サイト向けの全文検索エンジンです。サーバーサイドの処理が不要で、ビルド時に検索インデックスを生成し、フロントエンドのJavaScriptで高速な検索を実現します。

主な特徴:

  • 軽量: 検索インデックスサイズが小さい
  • 高速: クライアントサイドで瞬時に検索結果を表示
  • 静的: サーバーサイドの処理が不要
  • 多言語対応: 日本語を含む多言語の検索に対応

前提条件

  • Next.jsプロジェクトがセットアップされていること
  • 静的サイト生成(SSG)でサイトをビルドしていること

インストールと基本セットアップ

1. Pagefindのインストール

npm install -D pagefind

2. package.jsonにビルドスクリプトを追加

{
  "scripts": {
    "build": "next build && next export && npx pagefind --source out"
  }
}

3. 検索対象ページにdata-pagefind-bodyを追加

検索対象にしたいページのメインコンテンツ部分にdata-pagefind-body属性を追加します:

export default function BlogPost({ content }) {
  return (
    <article data-pagefind-body>
      <h1>{content.title}</h1>
      <div>{content.body}</div>
    </article>
  )
}

検索コンポーネントの実装

基本的な検索コンポーネント

import { useState, useEffect, useRef } from 'react'

declare global {
  interface Window {
    pagefind?: any
    searchTimeout?: NodeJS.Timeout
  }
}

export const SearchComponent = () => {
  const [isSearchOpen, setIsSearchOpen] = useState(false)
  const [searchQuery, setSearchQuery] = useState('')
  const [searchResults, setSearchResults] = useState<any[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const searchRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const loadPagefind = async () => {
      try {
        if (!document.getElementById('pagefind-module')) {
          const script = document.createElement('script')
          script.id = 'pagefind-module'
          script.type = 'module'
          script.innerHTML = `
            import * as pagefind from '/pagefind/pagefind.js';
            window.pagefind = pagefind;
            await pagefind.init();
          `
          document.head.appendChild(script)
        }
      } catch (error) {
        console.warn('Pagefind loading failed:', error)
      }
    }

    loadPagefind()
  }, [])

  const handleSearch = async (query: string) => {
    if (!query.trim()) {
      setSearchResults([])
      return
    }

    if (!window.pagefind) {
      console.warn('Pagefind not loaded yet')
      return
    }

    setIsLoading(true)
    try {
      const results = await window.pagefind.search(query)
      const processedResults = await Promise.all(
        results.results.slice(0, 10).map(async (result: any) => {
          const data = await result.data()
          let url = data.url || data.raw_url || ''
          // .html拡張子を除去してNext.jsルートに対応
          if (url.endsWith('.html')) {
            url = url.slice(0, -5)
          }
          return {
            url,
            title: data.meta?.title || data.title || 'Untitled',
            excerpt: data.excerpt || '',
          }
        })
      )
      setSearchResults(processedResults)
    } catch (error) {
      console.error('Search error:', error)
      setSearchResults([])
    }
    setIsLoading(false)
  }

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const query = e.target.value
    setSearchQuery(query)
    
    clearTimeout(window.searchTimeout)
    window.searchTimeout = setTimeout(() => {
      if (query.length > 0) {
        handleSearch(query)
      } else {
        setSearchResults([])
      }
    }, 300)
  }

  return (
    <div className="w-full max-w-xl mx-auto mb-8 relative" ref={searchRef}>
      <input
        type="text"
        value={searchQuery}
        onChange={handleInputChange}
        onFocus={() => setIsSearchOpen(true)}
        placeholder="記事を検索..."
        className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
      />
      
      {isSearchOpen && searchResults.length > 0 && (
        <div className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg">
          {searchResults.map((result, index) => (
            <a
              key={index}
              href={result.url}
              className="block p-4 hover:bg-gray-50 border-b last:border-b-0"
              onClick={() => setIsSearchOpen(false)}
            >
              <h3 className="font-semibold text-gray-900 mb-1">
                {result.title}
              </h3>
              <div 
                className="text-sm text-gray-600"
                dangerouslySetInnerHTML={{ __html: result.excerpt }}
              />
            </a>
          ))}
        </div>
      )}
    </div>
  )
}

よくある問題と解決方法

1. "Cannot use 'import.meta' outside a module"エラー

問題: PagefindはES Modulesを使用しているため、通常のscriptタグで読み込むとエラーが発生します。

解決方法: scriptタグにtype="module"を指定してES Moduleとして読み込みます:

const script = document.createElement('script')
script.type = 'module'  // 重要: この行を追加
script.innerHTML = `
  import * as pagefind from '/pagefind/pagefind.js';
  window.pagefind = pagefind;
  await pagefind.init();
`

2. 検索結果のURLが404エラーになる

問題: PagefindがHTMLファイルのパス(例:/posts/article.html)を返すが、Next.jsでは拡張子なしのパス(/posts/article)でアクセスする必要があります。

解決方法: 検索結果処理時に.html拡張子を除去します:

let url = data.url || data.raw_url || ''
if (url.endsWith('.html')) {
  url = url.slice(0, -5)  // .htmlを除去
}

3. 日本語入力時の検索タイミング問題

問題: 日本語のIME入力中に検索が実行されてしまい、意図しない検索結果が表示されます。

解決方法: IMEのcompositionイベントを監視して、確定後に検索を実行します:

const [isComposing, setIsComposing] = useState(false)

const handleCompositionStart = () => {
  setIsComposing(true)
}

const handleCompositionEnd = (e) => {
  setIsComposing(false)
  const query = e.target.value
  if (query.length > 0) {
    handleSearch(query)
  }
}

// input要素に追加
<input
  onCompositionStart={handleCompositionStart}
  onCompositionEnd={handleCompositionEnd}
  // ...その他のprops
/>

検索結果のハイライト表示

Pagefindは検索キーワードを<mark>タグでハイライトしてくれます。CSSでスタイルを設定しましょう:

mark {
  background-color: #fef08a;
  color: #1f2937;
  font-weight: 600;
  padding: 0 2px;
  border-radius: 2px;
}

Tailwind CSSを使用している場合:

className="[&_mark]:bg-yellow-200 [&_mark]:text-gray-900 [&_mark]:font-semibold"

パフォーマンス最適化

検索結果の制限

大量の検索結果がある場合は、表示数を制限します:

results.results.slice(0, 10)  // 最初の10件のみ表示

デバウンス処理

ユーザーの入力に対して即座に検索を実行せず、一定時間待ってから検索を実行します:

clearTimeout(window.searchTimeout)
window.searchTimeout = setTimeout(() => {
  if (query.length > 0) {
    handleSearch(query)
  }
}, 300)  // 300ms待機

まとめ

この記事では、Next.jsサイトにPagefind検索機能を実装する方法を詳しく解説しました。Pagefindを使用することで、サーバーレスで高速な検索機能を簡単に追加できます。

実装時のポイント:

  • ES Moduleとしての正しい読み込み
  • Next.jsルートに対応したURL正規化
  • 日本語IME対応の実装
  • 適切なデバウンス処理

これらの対策を行うことで、ユーザーフレンドリーな検索体験を提供できるでしょう。