Next.jsで低コストな検索フォームを作る

カテゴリーとタグをカスタムで実装したけど、本文をキーワード検索できたらもっと便利だなぁと思って、検索フォームを作ってみたよ。
このブログはコンテンツが全部マークダウンに保存されてるから、マークダウンの中身をそのまま検索する原始的スタイルだよ。
でも、これが思ったより大変だったよ!
検索フォームの作成
まずは検索フォームのコンポーネントを作ってフッターに配置したよ。これはほぼGPTの作ってくれたコードでできたよ。
import { useState } from 'react';
import { useRouter } from 'next/navigation';
const Search = () => {
const [query, setQuery] = useState('');
const router = useRouter();
const handleSearch = () => {
if (query.trim()) {
router.push(`/search?query=${encodeURIComponent(query)}`);
}
};
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="border border-gray-400 rounded-lg p-2"
/>
<button onClick={handleSearch} className="ml-4 bg-gray-700 hover:bg-gray-200 text-white py-2 px-4 border border-gray-50 rounded">Search</button>
</div>
);
};
export default Search;
ポイントは以下だよ
const [query, setQuery] = useState('');
のReactフックとinputタグのonChange
イベントで、フォームの値が変わるたびにステートのquery
の値が変わるよrouter.push(
/search?query=${encodeURIComponent(query)});
でURLにクエリパラメーターを追加してるよ
(あれ?なんでuse client
使ってないのに動いてるんだろう??と思ったら、Ract HookとかNext.jsのuserRouter使ってるときはNext.jsが勝手ににクライアントサイドだって判断してくれるので不要らしいよ。)
検索結果画面
次に、検索結果の画面を作ったよ。
最初は、このファイル内で
- クエリパラメーターを受け取る
- もともとlib/api.tsにある
getAllPosts()
などの関数を使ってファイルを検索する - 結果のJSXを返す
の3ステップできるだろうって思ってたんだけど、できなかったよ。
できなかった理由
- まず、クエリパラメーターを取得したり、コンポーネントを動的に更新したりするから、このファイルはクライアントサイドの処理を書くファイルになるよ。
- でも、「ファイルを検索する」はサーバー側のファイルを確認する必要があるから、クライアント側からは呼び出せなくて、エラーが出るよ。
- その他にも、useSearchParams()というクエリパラメーターを取得する関数の性質上、ページのtsxファイルと検索結果のコンポーネントは別々にしないとだめだったよ。
最終的にこんな構成になったよ。
検索結果ページ
"use client"
...
export default function search() {
return (
<Container>
<Header />
<Suspense fallback={<div>Loading...</div>}>
<SearchResult />
</Suspense>
</Container>
);
}
src/app/search/page.tsx
- **
use client
必要。**多分Suspense
タグが非同期的に変わるからだよ - 返り値のJSXの
Suspense
タグの中で「検索結果の中身」コンポーネント(後述)を呼び出してるよ
「検索結果の中身(タイトル+記事のリスト)」コンポーネント
"use client"
...
export default function searchResult() {
const searchParams = useSearchParams();
const [data, setData] = useState<Post[] | null>(null);
useEffect(() => {
async function fetchData() {
const paramValue = searchParams.get('query');
if (!paramValue) return;
try {
const response = await fetch('/api/search?q=' + paramValue);
const jsonData = await response.json();
setData(jsonData);
}
catch (error) {
console.error('Error fetching data:', error);
}
}
fetchData();
}, [searchParams]);
const paramValue = searchParams.get('query');
const title = paramValue + "の検索結果";
const posts = data || [];
return (
<div>
<PageTitle title={title}/>
<main>
<Container>
<Stories posts={posts} />
</Container>
</main>
</div>
);
}
src/app/_components/search-result.tsx
use client
必要。- なんでわざわざ検索結果ページから切り離したかというと、
useSearchParams()
を使う場合は以下をセットで行うという特別ルールがあるためだよ(これは親が子に依存してる気がして個人的にちょっとモヤるよ)- 呼び出し側で
Suspense
タグを使う - 呼び出される側で
useSearchParams()
を使う
- 呼び出し側で
- (ちなみに
Suspense
タグを使わなかったら使わないでエラーになるよ) useSearchParams()
でクエリパラメーターを取得してるよuseEffect
という、コンポーネントの初期レンダリング後に走るReachフックの中でサーバーのAPIから非同期的に記事を取得してるよ- 返り値のJSXの中で「記事のリスト」コンポーネント(後述)を呼び出してるよ
「記事のリスト」コンポーネント
import { Post } from "@/interfaces/post";
import { PostPreview } from "./post-preview";
type Props = {
posts: Post[];
};
export function Stories({ posts }: Props) {
return (
<section>
<div className="grid grid-cols-1 mb-32">
{posts.map((post) => (
<PostPreview
key={post.slug}
title={post.title}
tags={post.tags}
coverImage={post.coverImage}
date={post.date}
slug={post.slug}
/>
))}
</div>
</section>
);
}
src/app/_components/stories.tsx
- 記事のタイトルや日付などだよ
- 検索のために新しく作ったわけではなくて、もともとフロントやタグ一覧でも使われていたコンポーネントだよ(つまりSSGでも使えるよ)
use client
とは明示されていないけど、今回は検索ページ(クライアントサイド)→検索結果の中身(クライアントサイド)→記事のリスト(これ)っていう流れで呼び出されてるから、たぶんクライアント側で動作してるよ
検索結果取得用API
import { getAllPosts } from "@/lib/api";
import { NextRequest } from 'next/server';
export async function GET(req : NextRequest) {
const { searchParams } = new URL(req.url);
const query = searchParams.get('q') || '';
const posts = getAllPosts()
.filter((post) => {
return (
post.title.includes(query)
|| post.category.includes(query)
|| post.tags.includes(query)
|| post.content.includes(query)
);
});
return new Response(JSON.stringify(posts), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}
src/app/api/search/route.ts
- サーバーサイドの処理なので
use client
なしだよ async function
でファイルから検索結果を取得してるよ
非同期処理のポイント
新しいことが多すぎていろいろ書いちゃったけど、ポイントはこういうことかなと思ったよ
- サーバーサイドでしかできない処理、フロントでしかできない処理を切り分けよう
- サーバーサイドの処理をフロントから呼び出すときは、APIを作成しよう
- データのフェッチには
useEffect
を使い、レンダリング後でも非同期的に値が更新されるようにしよう - 時間がかかる処理は
async function
として定義、呼び出し側はawait
で呼び出そう
全体的に難しかったけどGPTのおかげでなんとか一晩でできたよ。そういえばNext.jsはログの内容をGPTに聞いても原因がわからない、っていう状況になったことがなくて、びっくらぽん。
ファイル検索は重い処理だけど、サーバーサイドでやってるからブラウザの負担は少ないはずだよ。今現在(記事20本程度)だとサクサク表示されるよ。
インデックスも検索用バックエンドもないから高度な検索はできないけど、1、2ファイル見れば何やってるかわかるっていう、質素なスタイルが気に入ってるよ笑