Blog

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

Cover Image for 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ファイル見れば何やってるかわかるっていう、質素なスタイルが気に入ってるよ笑