はじめに
個人開発で「BassTerms」というバス釣り用語辞典サイトを作りました。
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点について、具体的なコードとともにまとめます。
- サーバーなしでかな/カナの表記ゆれを吸収する検索を実装する
-
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/ で公開しているので、よければ見てみてください。