1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js静的エクスポートで用語辞典アプリを作るときにハマった2つのポイント(表記ゆれ検索・非ASCII動的ルート)

1
Posted at

はじめに

個人開発で「BassTerms」というバス釣り用語辞典サイトを作りました。

https://bassterms.com/

Next.js(App Router)の output: "export" による完全静的サイトで、300件超の用語をMarkdown+Frontmatterで管理しています。技術スタックは以下の通りです。

  • Next.js 16(App Router、静的エクスポート)
  • TypeScript
  • Tailwind CSS v4 + shadcn/ui
  • gray-matter(Markdown + Frontmatterパース)

この記事では、開発中に実際にハマった以下の2点について、具体的なコードとともにまとめます。

  1. サーバーなしでかな/カナの表記ゆれを吸収する検索を実装する
  2. output: "export" の動的ルートで日本語パラメータを使うと詰む

1. サーバーレスでも表記ゆれを吸収したい

課題

用語辞典なので検索機能は必須ですが、静的エクスポートなのでバックエンドの全文検索エンジンは使えません。かつ、日本語の検索では「ひらがな」「カタカナ」「濁点・半濁点」の表記ゆれを吸収したい要件がありました。

例えば「がいど」と入力しても「ガイド」がヒットしてほしいですし、逆に「ガイド」と入力しても「がいど」を想定した項目がヒットしてほしい。全角カタカナ・ひらがなが混在した状態で単純な文字列一致(includes)をすると、これが素直には成立しません。

解決策: 検索前にすべてひらがなへ正規化する

用語データにはあらかじめ読み仮名 (kana) を持たせているので、「検索対象の文字列」と「検索クエリ」の両方を同じ正規化関数に通してから比較する方針にしました。

// src/lib/kana.ts
function toHiragana(char: string): string {
  const code = char.charCodeAt(0);
  // 全角カタカナ(U+30A1-U+30F6) -> ひらがな(U+3041-U+3096)
  if (code >= 0x30a1 && code <= 0x30f6) {
    return String.fromCharCode(code - 0x60);
  }
  return char;
}

/** 文字列全体をひらがなに正規化する(検索でのかな/カナ表記ゆれ吸収用) */
export function toHiraganaString(str: string): string {
  return Array.from(str).map(toHiragana).join("");
}

カタカナとひらがなはUnicode上で0x60(96)のオフセットで対応しているため、コードポイントをずらすだけで変換できます。これを検索対象の文字列生成(用語名・読み仮名・英語表記・要約を連結したもの)とクエリ文字列の両方に適用します。

// src/components/term-browser.tsx
function buildHaystack(term: Term): string {
  return toHiraganaString(
    [term.term, term.kana, term.enTerm, term.summary].filter(Boolean).join(" "),
  ).toLowerCase();
}

// フィルタリング時
const normalizedQuery = toHiraganaString(query.trim()).toLowerCase();
const filtered = indexed.filter(({ haystack }) =>
  !normalizedQuery || haystack.includes(normalizedQuery)
);

これで「ひらがな/カタカナどちらで入力しても一致する」検索が、外部ライブラリなしで実現できます。用語数が300件程度であれば Array.prototype.includes の逐次比較でも体感速度は問題になりませんでした(数千件規模になるなら転置インデックスやFlexSearch等の導入を検討する余地があります)。

おまけ: 検索状態をヘッダーと一覧ページで共有する

検索欄はヘッダー(どのページからでも入力可能)に置き、フィルタリング処理はホーム画面のコンポーネントが行う、という構成にしています。この2つは離れたコンポーネントなので、React Contextで状態を共有し、sessionStorage に永続化してタブを閉じるまで保持するようにしました。

// src/components/term-filter-provider.tsx (抜粋)
const STORAGE_KEY = "bassterms:term-browser-filters";

useEffect(() => {
  const stored = readStoredFilters(); // sessionStorage.getItem を try/catch で読む
  if (typeof stored.query === "string") setQuery(stored.query);
  // ...
  setHydrated(true);
}, []);

useEffect(() => {
  if (!hydrated) return;
  window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ query, categories, matchMode, favoritesOnly }));
}, [hydrated, query, categories, matchMode, favoritesOnly]);

sessionStorage はサーバー側で読めないため、初回描画は空の状態で返し、マウント後の useEffect で読み込む2段構えにしているのがポイントです。これをやらないとSSG時点のHTMLとクライアントの初期状態がズレてhydrationエラーになります。

2. output: "export" の動的ルートに日本語パラメータを使うと詰む

課題

カテゴリ別の一覧ページ (/categories/[category]) を作る際、最初は素直に日本語のカテゴリ名(「タックル」「ルアー」など)をそのまま動的ルートのパラメータに使おうとしました。

// うまくいかない例
export function generateStaticParams() {
  return CATEGORIES.map((category) => ({ category })); // 例: { category: "タックル" }
}

next dev では問題なく動くのですが、output: "export" で静的ビルドした成果物 (out/) を配信すると、/categories/タックル へのアクセスが404になる、という現象が発生しました。非ASCII文字を含む動的パラメータが、静的エクスポート後のstatic paramマッチングで正しく解決されないことが原因です。

解決策: ASCIIスラッグを経由してマッピングする

日本語のカテゴリ名をURLに直接使うのをやめ、ASCIIのスラッグに変換するマッピング層を用意しました。

// src/lib/categories.ts
export const CATEGORY_SLUGS: Record<Category, string> = {
  タックル: "tackle",
  ルアー: "lure",
  リグ: "rig",
  "釣法・アクション": "technique",
  "魚の生態・行動": "ecology",
  フィールド用語: "field",
  その他: "other",
};

const SLUG_TO_CATEGORY: Record<string, Category> = Object.fromEntries(
  CATEGORIES.map((category) => [CATEGORY_SLUGS[category], category]),
) as Record<string, Category>;

export function getCategoryBySlug(slug: string): Category | undefined {
  return SLUG_TO_CATEGORY[slug];
}

generateStaticParams はこのASCIIスラッグを返し、ページコンポーネント側で getCategoryBySlug を使って日本語のカテゴリ名に逆変換してから表示用に使います。UI上の表示(日本語カテゴリ名)とURL(ASCIIスラッグ)を完全に分離することで、静的エクスポートでも安全に動くようになりました。

日本語を含む動的ルートを output: "export" で使う場合は、最初からASCIIスラッグ経由の設計にしておくのが無難です。

まとめ

  • サーバーなしの日本語表記ゆれ検索は、ひらがな正規化関数を1つ作って検索対象・クエリの両方に通すだけでかなり実用的になる
  • output: "export" の動的ルートに非ASCIIパラメータを直接使うのは避け、ASCIIスラッグとのマッピング層を挟む

どちらも大掛かりなライブラリを使わず、数十行の実装で解決できた点が個人開発的には気に入っています。BassTermsは https://bassterms.com/ で公開しているので、よければ見てみてください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?