Next.js（App Router）での検索機能について

# 概要
自身が開発しているブログアプリに**検索機能**を追加しました :tada:

検索コンポーネントの検索結果に応じて、ページ内の`posts`データをフィルタリングする`UX`を実現したかったのですが、実装方針に迷いがあり、記事にまとめました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842674/edc2dbe6-9c9f-43ba-ba52-2993fd9d31f3.png)

# 結論

以下の流れで機能実装しました。
1. `SearchBox`（`Client Component`）で文字列変更時に`router.push`
2. `page.tsx`で検索文字列に応じた`posts`を再取得

```mermaid
sequenceDiagram
    participant ユーザー as User
    participant SearchBox as SearchBox (Client)
    participant Router as Next.js Router
    participant PageTSX as page.tsx (Server)
    participant API as データ取得関数

    ユーザー->>SearchBox: 検索ワードを入力
    Note over SearchBox: useDebouncedCallback (500ms)
    SearchBox->>Router: router.push("/?q={ワード}")
    Router->>PageTSX: URLパラメータを渡す<br>searchParams = { q: "ワード" }
    
    alt クエリパラメータあり (q != '')
        PageTSX->>API: searchPosts({query: ワード, ...})
        API-->>PageTSX: 検索結果を返却
    else クエリパラメータなし
        PageTSX->>API: getPosts({...})
        API-->>PageTSX: 全記事データを返却
    end
    
    PageTSX-->>ユーザー: 結果を表示
    
    Note over SearchBox,API: 検索から表示までの流れ
```

# 実装

それでは実装していきます。
今回の実装では、私のアプリで使用している一部コンポーネントの記述を抜き出して解説しています。
適宜自身の環境に置き換えてご確認ください🙇‍♂️

## SearchBox.tsx
検索を行う `SearchBox` を実装します。

入力を監視し、デバウンス処理を通じてクエリ付きのURLに遷移することで、検索結果を反映させます。

```tsx
'use client';

import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import styles from './SearchBox.module.css';

export default function SearchBox() {
  const searchParams = useSearchParams();
  const [search, setSearch] = useState(searchParams.get('q') || '');
  const [debouncedSearch, setDebouncedSearch] = useState(search);
  const router = useRouter();

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedSearch(search);
    }, 500);

    return () => clearTimeout(timer);
  }, [search]);

  useEffect(() => {
    if (debouncedSearch.trim()) {
      router.push(`/?q=${debouncedSearch.trim()}`);
    } else if (debouncedSearch === '') {
      router.push('/');
    }
  }, [debouncedSearch, router]);

  return (
    <div className={styles.searchContainer}>
      <div className={styles.searchBox}>
        <input
          type="text"
          placeholder="記事を検索..."
          className={styles.searchInput}
          value={search}
          onChange={(e) => setSearch(e.target.value)}
        />
        <div className={styles.searchIcon}>
          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
            <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
          </svg>
        </div>
      </div>
    </div>
  );
}
```

## page.tsx

`page.tsx` では、`URL`のクエリパラメータ（`searchParams`）を元に、検索キーワードが存在するかどうかを判定しています。

- 検索キーワードがある場合は `searchPosts()` を実行
- ない場合は通常の `getPosts()` を実行

といった条件分岐で表示する投稿データを切り替えています。

```tsx
import Card from "@/components/ui/Card/Card";
import styles from './page.module.css';
import Link from "next/link";
import { getPosts, searchPosts } from "@/app/(public)/posts/fetcher";
import { PostsResponse } from "../types";

type PageProps = {
  searchParams: Promise<{
    cursor?: string;
    q?: string;
  }>;
};

export default async function Home({ searchParams }: PageProps) {
  const queryParams = await searchParams;
  const cursor = queryParams.cursor || undefined;
  const searchQuery = queryParams.q || '';

  const data = searchQuery
    ? await searchPosts({ query: searchQuery, first: 15, after: cursor })
    : await getPosts({ first: 15, after: cursor });

  const posts = await data.json() as PostsResponse;

  return (
    <div>
      {searchQuery && (
        <h1 className={styles.searchTitle}>「{searchQuery}」の検索結果</h1>
      )}

      {posts.edges.length === 0 && searchQuery && (
        <p className={styles.noResults}>検索結果が見つかりませんでした。別のキーワードをお試しください。</p>
      )}

      <div className={styles.cardContainer}>
        {posts.edges.map(({ node: post }) => (
          <Link href={`/posts/${post.id}`} key={post.id}>
            <Card
              img={post.thumbnailUrl}
              title={post.title}
              description={post.content}
              variant="post"
              unoptimized={true}
              maxLines={3}
              titleMaxLines={true}
            />
          </Link>
        ))}
      </div>

      <div className={styles.paginationContainer}>
        {posts.pageInfo.hasPreviousPage && (
          <Link href={`/?${searchQuery ? `q=${searchQuery}&` : ''}cursor=${posts.pageInfo.startCursor}`} className={styles.paginationLink}>
            前のページ
          </Link>
        )}
        {posts.pageInfo.hasNextPage && (
          <Link href={`/?${searchQuery ? `q=${searchQuery}&` : ''}cursor=${posts.pageInfo.endCursor}`} className={styles.paginationLink}>
            次のページ
          </Link>
        )}
      </div>
    </div>
  );
}
```

# まとめ
今までNextではプルダウン形式の検索機能しか採用していなかった...
日々学びですね 🔍