WithCodeMedia-1-pc
previous arrowprevious arrow
next arrownext arrow

WithCodeMedia-1-sp
previous arrowprevious arrow
next arrownext arrow

【実装ガイド】ヘッドレスCMSでサイト内検索を実装!WordPress×Astroで完全構築の手順を詳しく解説

生徒

ヘッドレスCMSでサイト内検索を作りたいんですが、どうやって実装すれば良いでしょうか?

ペン博士

よーく聞くんだぞ!WordPressをヘッドレスCMSとして使い、AstroとREST APIを組み合わせれば、高速で柔軟な検索機能が作れるんじゃ。今日は実装方法を詳しく解説するぞい!

目次

この記事でわかること

  • ヘッドレスCMSとは何か、従来のWordPressとの違いと採用するメリット
  • WordPress REST APIの検索パラメータの使い方とAPIラッパー関数の実装
  • React Hook Form+Zodで型安全な検索フォームを作る方法
  • Astroのハイブリッドレンダリング(SSR)で検索結果ページを実装する手順
  • カテゴリーフィルター・ページネーション・キャッシュによるパフォーマンス最適化

ヘッドレスCMSを使ったWebサイト制作が増えている中、サイト内検索機能の実装は避けて通れない課題です。従来のWordPressテーマと異なり、フロントエンドとバックエンドが分離されているため、検索機能も独自に実装する必要があります。

本記事では、WordPress REST APIとAstroを使った高速なサイト内検索の実装方法を、基礎から応用まで、実装例を交えて徹底的に解説します。


ヘッドレスCMSとは?従来のWordPressとの違い

ヘッドレスCMS(Headless CMS)とは、コンテンツ管理機能(バックエンド)とフロントエンドの表示を完全に分離したCMSアーキテクチャです。

特徴従来のCMSヘッドレスCMS
構成バックエンドとフロントエンドが一体完全に分離
表示方法CMSのテーマシステム任意のフロントエンド技術
データ取得内部処理REST API / GraphQL
パフォーマンスサーバー処理に依存静的生成で高速化可能
柔軟性テーマの制約あり完全にカスタマイズ可能
マルチチャネルWeb専用Web / アプリ / IoT等に対応

ヘッドレスWordPressのメリット

  • 使い慣れたWordPress管理画面:コンテンツ管理はWordPressの優れたUIをそのまま活用
  • 豊富なプラグイン:SEO・セキュリティ・メディア管理などの既存プラグインが利用可能
  • 強力なREST API:標準でREST APIが用意されており、すぐに使える
  • 高速なフロントエンド:Astro・Next.js・Nuxtなどのモダンフレームワークで高速化
  • セキュリティ向上:フロントエンドとバックエンドが分離され、攻撃面が減少
  • スケーラビリティ:フロントエンドをCDNで配信し、グローバルに高速配信

Astroとは?

Astroは、高速な静的サイト生成に特化したモダンなWebフレームワークです。ゼロJavaScriptがデフォルトで、必要な部分だけにJavaScriptを配信する「部分的ハイドレーション」が特徴です。React・Vue・Svelteなどを自由に組み合わせられ、ページ単位で静的生成とSSRを選択できます。


サイト内検索機能の設計

生徒

検索機能を作る前に、どんな設計にすれば良いですか?

ペン博士

まずは全体のアーキテクチャを理解するんじゃ!ユーザーの検索クエリをどう処理し、結果をどう表示するか、全体の流れを把握することが大切じゃぞ

検索機能のアーキテクチャ

  1. 検索フォームの入力:ユーザーが検索キーワードを入力
  2. バリデーション:Zodで入力値を検証
  3. ページ遷移:クエリパラメータ付きで検索結果ページへ
  4. SSRでAPIリクエスト:サーバー側でWordPress REST APIを呼び出し
  5. 結果の取得・処理:検索結果を取得して処理
  6. 結果の表示:検索結果をレンダリング

使用する技術スタック

役割技術
バックエンド(CMS)WordPress(ヘッドレス)
APIWordPress REST API
フロントエンドAstro
UIコンポーネントReact(部分的に使用)
フォーム管理React Hook Form
バリデーションZod
スタイリングTailwind CSS

ステップ1:プロジェクトのセットアップ

# Astroプロジェクトの作成
npm create astro@latest my-headless-site

# プロジェクトディレクトリに移動
cd my-headless-site

# 必要なパッケージをインストール
npm install

# Reactインテグレーションを追加
npx astro add react

# Tailwind CSSを追加
npx astro add tailwind

# その他の依存関係をインストール
npm install react-hook-form zod @hookform/resolvers

環境変数の設定

# .env
# WordPress REST APIのベースURL
PUBLIC_WP_API_URL=https://your-wordpress-site.com/wp-json/wp/v2

# 1ページあたりの表示件数
PUBLIC_POSTS_PER_PAGE=10

Astro設定ファイルの調整

// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';

export default defineConfig({
  integrations: [react(), tailwind()],
  output: 'hybrid', // 部分的SSRを有効化
});

ステップ2:WordPress REST APIの理解と活用

検索に使える主要パラメータ

# 投稿一覧を取得
GET /wp-json/wp/v2/posts

# 検索パラメータ付きで取得
GET /wp-json/wp/v2/posts?search=キーワード

# カテゴリーでフィルタリング
GET /wp-json/wp/v2/posts?categories=1,2

# ページネーション
GET /wp-json/wp/v2/posts?page=1&per_page=10

# 並び順
GET /wp-json/wp/v2/posts?orderby=date&order=desc
パラメータ説明
searchタイトル・本文から検索search=WordPress
categoriesカテゴリーIDcategories=1,5
tagsタグIDtags=10,20
author著者IDauthor=2
orderby並び順の基準orderby=date
order昇順/降順order=asc
pageページ番号page=2
per_page1ページあたりの件数per_page=20

APIラッパー関数の作成

// src/lib/wordpress.ts
const WP_API_URL = import.meta.env.PUBLIC_WP_API_URL;

export type Post = {
  id: number;
  date: string;
  title: {
    rendered: string;
  };
  content: {
    rendered: string;
  };
  excerpt: {
    rendered: string;
  };
  link: string;
  slug: string;
  categories: number[];
  tags: number[];
  _embedded?: {
    'wp:featuredmedia'?: Array<{
      source_url: string;
      alt_text: string;
    }>;
  };
};

export type SearchParams = {
  search?: string;
  categories?: string;
  tags?: string;
  page?: number;
  perPage?: number;
  orderby?: 'date' | 'relevance' | 'title';
  order?: 'asc' | 'desc';
};

export async function searchPosts(params: SearchParams = {}) {
  const {
    search = '',
    categories = '',
    tags = '',
    page = 1,
    perPage = 10,
    orderby = 'date',
    order = 'desc',
  } = params;

  // クエリパラメータの構築
  const queryParams = new URLSearchParams({
    _embed: 'true', // アイキャッチ画像を含める
    page: page.toString(),
    per_page: perPage.toString(),
    orderby,
    order,
  });

  // 検索キーワードがある場合のみ追加
  if (search) {
    queryParams.append('search', search);
  }

  // カテゴリーがある場合のみ追加
  if (categories) {
    queryParams.append('categories', categories);
  }

  // タグがある場合のみ追加
  if (tags) {
    queryParams.append('tags', tags);
  }

  const url = `${WP_API_URL}/posts?${queryParams.toString()}`;

  try {
    const response = await fetch(url);

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const posts: Post[] = await response.json();

    // レスポンスヘッダーから総件数を取得
    const totalPosts = parseInt(
      response.headers.get('X-WP-Total') || '0',
      10
    );
    const totalPages = parseInt(
      response.headers.get('X-WP-TotalPages') || '0',
      10
    );

    return {
      posts,
      totalPosts,
      totalPages,
      currentPage: page,
    };
  } catch (error) {
    console.error('Error fetching posts:', error);
    return {
      posts: [],
      totalPosts: 0,
      totalPages: 0,
      currentPage: page,
    };
  }
}

// カテゴリー一覧を取得
export async function getCategories() {
  const url = `${WP_API_URL}/categories?per_page=100`;

  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error('Error fetching categories:', error);
    return [];
  }
}

ステップ3:検索フォームの実装

React Hook FormとZodを使った型安全な検索フォームを作成します。

// src/components/SearchForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// バリデーションスキーマの定義
const searchSchema = z.object({
  q: z
    .string()
    .min(1, '検索キーワードを入力してください')
    .max(100, '検索キーワードは100文字以内で入力してください'),
});

type SearchFormData = z.infer<typeof searchSchema>;

export function SearchForm({ defaultValue = '' }: { defaultValue?: string }) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SearchFormData>({
    resolver: zodResolver(searchSchema),
    defaultValues: {
      q: defaultValue,
    },
  });

  const onSubmit = (data: SearchFormData) => {
    // 検索結果ページへ遷移
    window.location.href = `/search?q=${encodeURIComponent(data.q)}`;
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="w-full max-w-2xl">
      <div className="relative">
        <input
          type="text"
          {...register('q')}
          placeholder="キーワードを入力..."
          className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
          aria-label="検索キーワード"
        />

        <button
          type="submit"
          disabled={isSubmitting}
          className="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:bg-gray-400 transition-colors"
          aria-label="検索"
        >
          {isSubmitting ? (
            <svg
              className="animate-spin h-5 w-5"
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 24 24"
            >
              <circle
                className="opacity-25"
                cx="12"
                cy="12"
                r="10"
                stroke="currentColor"
                strokeWidth="4"
              />
              <path
                className="opacity-75"
                fill="currentColor"
                d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
              />
            </svg>
          ) : (
            <svg
              className="h-5 w-5"
              fill="none"
              stroke="currentColor"
              viewBox="0 0 24 24"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
              />
            </svg>
          )}
        </button>
      </div>

      {errors.q && (
        <p className="mt-2 text-sm text-red-600" role="alert">
          {errors.q.message}
        </p>
      )}
    </form>
  );
}

ステップ4:検索結果ページの実装(SSR)

検索結果ページは動的にクエリパラメータを処理する必要があるため、SSRを有効にします。export const prerender = falseを設定することで、ビルド時に静的化されず、リクエストごとに動的にレンダリングされます。

---
// src/pages/search.astro

// このページのみSSRを有効化
export const prerender = false;

import Layout from '../layouts/Layout.astro';
import { SearchForm } from '../components/SearchForm';
import { searchPosts } from '../lib/wordpress';

// URLからクエリパラメータを取得
const url = new URL(Astro.request.url);
const searchParams = new URLSearchParams(url.search);

const keyword = searchParams.get('q') || '';
const page = parseInt(searchParams.get('page') || '1', 10);
const categories = searchParams.get('categories') || '';

// WordPress REST APIで検索実行
const { posts, totalPosts, totalPages, currentPage } = await searchPosts({
  search: keyword,
  categories,
  page,
  perPage: 10,
  orderby: keyword ? 'relevance' : 'date',
});
---

<Layout title={`検索結果: ${keyword || 'すべての記事'}`}>
  <div class="container mx-auto px-4 py-8">
    <h1 class="text-3xl font-bold mb-8">
      {keyword ? `"${keyword}" の検索結果` : 'すべての記事'}
    </h1>

    <!-- 検索フォーム -->
    <div class="mb-8">
      <SearchForm client:load defaultValue={keyword} />
    </div>

    <!-- 検索結果の件数表示 -->
    {totalPosts > 0 && (
      <p class="text-gray-600 mb-6">
        {totalPosts}件の記事が見つかりました
      </p>
    )}

    <!-- 検索結果の表示 -->
    {posts.length > 0 ? (
      <div class="space-y-6">
        {posts.map((post) => (
          <article
            key={post.id}
            class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"
          >
            <!-- アイキャッチ画像 -->
            {post._embedded?.['wp:featuredmedia']?.[0] && (
              <img
                src={post._embedded['wp:featuredmedia'][0].source_url}
                alt={post._embedded['wp:featuredmedia'][0].alt_text}
                class="w-full h-48 object-cover rounded-lg mb-4"
                loading="lazy"
              />
            )}

            <!-- タイトル -->
            <h2 class="text-2xl font-bold mb-2">
              <a
                href={`/posts/${post.slug}`}
                class="text-blue-600 hover:text-blue-800 transition-colors"
                set:html={post.title.rendered}
              />
            </h2>

            <!-- 投稿日 -->
            <time class="text-sm text-gray-500 mb-4 block">
              {new Date(post.date).toLocaleDateString('ja-JP', {
                year: 'numeric',
                month: 'long',
                day: 'numeric',
              })}
            </time>

            <!-- 抜粋 -->
            <div
              class="text-gray-700 line-clamp-3"
              set:html={post.excerpt.rendered}
            />

            <!-- 続きを読むリンク -->
            <a
              href={`/posts/${post.slug}`}
              class="inline-block mt-4 text-blue-600 hover:text-blue-800 font-semibold"
            >
              続きを読む →
            </a>
          </article>
        ))}
      </div>
    ) : (
      <!-- 検索結果が0件の場合 -->
      <div class="text-center py-16">
        <svg
          class="mx-auto h-16 w-16 text-gray-400 mb-4"
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            stroke-width="2"
            d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
          />
        </svg>
        <h2 class="text-2xl font-semibold text-gray-700 mb-2">
          記事が見つかりませんでした
        </h2>
        <p class="text-gray-500">
          別のキーワードで検索してみてください
        </p>
      </div>
    )}

    <!-- ページネーション -->
    {totalPages > 1 && (
      <nav class="mt-12 flex justify-center" aria-label="ページネーション">
        <ul class="flex gap-2">
          {currentPage > 1 && (
            <li>
              <a
                href={`/search?q=${keyword}&page=${currentPage - 1}${
                  categories ? `&categories=${categories}` : ''
                }`}
                class="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
              >
                前へ
              </a>
            </li>
          )}

          {Array.from({ length: totalPages }, (_, i) => i + 1).map(
            (pageNum) => (
              <li key={pageNum}>
                {pageNum === currentPage ? (
                  <span class="px-4 py-2 bg-blue-500 text-white rounded-md">
                    {pageNum}
                  </span>
                ) : (
                  <a
                    href={`/search?q=${keyword}&page=${pageNum}${
                      categories ? `&categories=${categories}` : ''
                    }`}
                    class="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
                  >
                    {pageNum}
                  </a>
                )}
              </li>
            )
          )}

          {currentPage < totalPages && (
            <li>
              <a
                href={`/search?q=${keyword}&page=${currentPage + 1}${
                  categories ? `&categories=${categories}` : ''
                }`}
                class="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
              >
                次へ
              </a>
            </li>
          )}
        </ul>
      </nav>
    )}
  </div>
</Layout>

ステップ5:カテゴリーフィルターの実装

カテゴリーやタグでフィルタリングできる機能を追加します。

// src/components/SearchFilters.tsx
import { useState } from 'react';

type Category = {
  id: number;
  name: string;
  slug: string;
};

type Props = {
  categories: Category[];
  selectedCategories: string;
};

export function SearchFilters({ categories, selectedCategories }: Props) {
  const [selected, setSelected] = useState<Set<string>>(
    new Set(selectedCategories.split(',').filter(Boolean))
  );

  const handleToggle = (categoryId: string) => {
    const newSelected = new Set(selected);
    if (newSelected.has(categoryId)) {
      newSelected.delete(categoryId);
    } else {
      newSelected.add(categoryId);
    }
    setSelected(newSelected);
  };

  const handleApply = () => {
    const params = new URLSearchParams(window.location.search);
    if (selected.size > 0) {
      params.set('categories', Array.from(selected).join(','));
    } else {
      params.delete('categories');
    }
    params.set('page', '1'); // フィルター変更時は1ページ目に戻る
    window.location.href = `/search?${params.toString()}`;
  };

  return (
    <div className="bg-white rounded-lg shadow-md p-6">
      <h3 className="text-lg font-semibold mb-4">カテゴリーで絞り込み</h3>

      <div className="space-y-2 mb-4">
        {categories.map((category) => (
          <label
            key={category.id}
            className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 p-2 rounded"
          >
            <input
              type="checkbox"
              checked={selected.has(category.id.toString())}
              onChange={() => handleToggle(category.id.toString())}
              className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
            />
            <span className="text-gray-700">{category.name}</span>
          </label>
        ))}
      </div>

      <button
        onClick={handleApply}
        className="w-full px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
      >
        絞り込む
      </button>
    </div>
  );
}

パフォーマンス最適化

1. キャッシュの活用

// src/lib/wordpress.ts(キャッシュ版)
const CACHE_DURATION = 60 * 5; // 5分

const cache = new Map<string, { data: any; timestamp: number }>();

async function fetchWithCache(url: string) {
  const cached = cache.get(url);
  const now = Date.now();

  // キャッシュが有効な場合は返す
  if (cached && now - cached.timestamp < CACHE_DURATION * 1000) {
    return cached.data;
  }

  // APIリクエスト
  const response = await fetch(url);
  const data = await response.json();

  // キャッシュに保存
  cache.set(url, { data, timestamp: now });

  return data;
}

2. 遅延読み込みの実装

<!-- 画像の遅延読み込み -->
<img
  src={imageUrl}
  alt={altText}
  loading="lazy"
  decoding="async"
  class="w-full h-48 object-cover"
/>

生徒

ヘッドレスCMSでの検索実装、だいぶわかってきました!

ペン博士

その調子じゃ!最初はシンプルな検索から始めて、徐々にフィルター機能やページネーションを追加していくと良いぞ。ユーザー体験を第一に考えることが大切じゃ!

生徒

わかりました!早速自分のプロジェクトに実装してみます!


よくある質問

WordPress REST APIの検索は日本語に対応していますか?

はい、対応しています。ただし、日本語の場合はURLエンコードが必要です。JavaScriptのencodeURIComponent()を使用して検索クエリをエンコードすれば正しく動作します。また、WordPress側のデータベースの文字コードがUTF-8であることを確認してください。

Astroのハイブリッドモードとは何ですか?

Astroのoutput: 'hybrid'は、デフォルトで全ページを静的生成(SSG)しつつ、export const prerender = falseを指定したページのみSSR(サーバーサイドレンダリング)で動的に処理するモードです。検索結果ページのようにURLパラメータに応じて内容が変わるページに適しています。

検索の精度を上げるにはどうすればよいですか?

WordPress REST APIの標準検索はタイトルと本文のみ対象です。より高度な全文検索が必要な場合は、Elasticsearchと「ElasticPress」プラグインの組み合わせや、Algoliaとの連携を検討してください。また、orderby=relevanceを設定することで、検索キーワードとの関連度順に並び替えできます。

ヘッドレス構成でWordPressのSEOプラグインは使えますか?

Yoast SEOやRank MathなどのSEOプラグインはバックエンドで機能し、REST APIを通じてメタデータを取得できます。Yoast SEOの場合、?_fields=yoast_head_jsonパラメータを追加することでSEOメタデータをJSON形式で取得でき、フロントエンドのAstroで利用できます。

VercelやNetlifyへのデプロイはできますか?

可能です。AstroはVercel・Netlify・Cloudflare Pagesに公式対応しています。SSRを使用する場合はそれぞれのアダプター(@astrojs/vercel等)をインストールして設定してください。環境変数はデプロイ先のダッシュボードで設定します。


まとめ

本記事では、ヘッドレスCMS(WordPress)とAstroを使ったサイト内検索の実装方法を、基礎から応用まで詳しく解説しました。

  • ヘッドレスCMSの理解:バックエンドとフロントエンドを分離し、柔軟で高速な開発が可能になる
  • WordPress REST API:searchパラメータでタイトル・本文から検索、X-WP-Totalヘッダーで総件数取得
  • Astroのハイブリッドレンダリング:検索ページのみprerender = falseでSSRを有効化する
  • 型安全性の確保:Zodによるバリデーション、TypeScriptで堅牢な実装を実現する
  • UX重視の実装:ローディング表示・エラーハンドリング・ページネーションを必ず実装する
  • パフォーマンス最適化:5分キャッシュ・画像の遅延読み込みでAPIコールを削減する
  • 拡張性:カテゴリーフィルター・タグフィルター・並び替えの追加が容易な設計にする

WithCodeで学んだWeb制作の基礎知識に、ヘッドレスCMSの技術を組み合わせれば、高速で柔軟なモダンWebサイトを構築できます。ヘッドレスCMSとモダンフロントエンドフレームワークの組み合わせは、今後ますます主流になっていきます。この記事を参考に、ぜひあなたのプロジェクトでもヘッドレスCMSを活用したサイト内検索を実装してみてください。


関連記事


参考リンク(公式・一次情報)


WithCodeを体験できる初級コース公開中!

WithCode初級コース

初級コース(¥49,800)が完全無料に!

  • 期間:1週間
  • 学習内容:ロードマップ/基礎知識/環境構築/HTML/CSS/LP・ポートフォリオ作成
    → 正しい学習方法で「確かな成長」を実感できるカリキュラム

副業・フリーランスが主流になっている今こそ、自らのスキルで稼げる人材を目指してみませんか?

未経験でも心配することはありません。初級コースを受講される方の大多数はプログラミング未経験です。まずは無料カウンセリングで、悩みや不安をお聞かせください!

この記事を書いた人

WithCode(ウィズコード)は「目指すなら稼げる人材」をビジョンに、累計400名以上のフリーランスを輩出してきた超実践型プログラミングスクールです。150社以上の実案件支援を特徴にWeb制作・Webデザインなどの役立つ情報を現場のノウハウに基づいて発信していきます。

– service –WithGroupの運営サービス

  • WithCode
    - ウィズコード -

    スクール

    「未経験」から
    現場で通用する
    スキルを身に付けよう!

    詳細はこちら
  • WithFree
    - ウィズフリ -

    実案件サポート

    制作会社のサポート下で
    実務経験を積んでいこう!

    詳細はこちら
  • WithCareer
    - ウィズキャリ -

    就転職サポート

    大手エージェントのサポート下で
    キャリアアップを目指そう!

    詳細はこちら

公式サイト より
今すぐ
無料カウンセリング
予約!

目次