



WithCodeMedia-1-pc
WithCodeMedia-2-pc
WithCodeMedia-3-pc
WithCodeMedia-4-pc




WithCodeMedia-1-sp
WithCodeMedia-2-sp
WithCodeMedia-3-sp
WithCodeMedia-4-sp









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



よーく聞くんだぞ!WordPressをヘッドレスCMSとして使い、AstroとREST APIを組み合わせれば、高速で柔軟な検索機能が作れるんじゃ。今日は実装方法を詳しく解説するぞい!
ヘッドレスCMSを使ったWebサイト制作が増えている中、サイト内検索機能の実装は避けて通れない課題です。従来のWordPressテーマと異なり、フロントエンドとバックエンドが分離されているため、検索機能も独自に実装する必要があります。
本記事では、WordPress REST APIとAstroを使った高速なサイト内検索の実装方法を、基礎から応用まで、実装例を交えて徹底的に解説します。
ヘッドレスCMS(Headless CMS)とは、コンテンツ管理機能(バックエンド)とフロントエンドの表示を完全に分離したCMSアーキテクチャです。
| 特徴 | 従来のCMS | ヘッドレスCMS |
|---|---|---|
| 構成 | バックエンドとフロントエンドが一体 | 完全に分離 |
| 表示方法 | CMSのテーマシステム | 任意のフロントエンド技術 |
| データ取得 | 内部処理 | REST API / GraphQL |
| パフォーマンス | サーバー処理に依存 | 静的生成で高速化可能 |
| 柔軟性 | テーマの制約あり | 完全にカスタマイズ可能 |
| マルチチャネル | Web専用 | Web / アプリ / IoT等に対応 |
Astroは、高速な静的サイト生成に特化したモダンなWebフレームワークです。ゼロJavaScriptがデフォルトで、必要な部分だけにJavaScriptを配信する「部分的ハイドレーション」が特徴です。React・Vue・Svelteなどを自由に組み合わせられ、ページ単位で静的生成とSSRを選択できます。



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



まずは全体のアーキテクチャを理解するんじゃ!ユーザーの検索クエリをどう処理し、結果をどう表示するか、全体の流れを把握することが大切じゃぞ
| 役割 | 技術 |
|---|---|
| バックエンド(CMS) | WordPress(ヘッドレス) |
| API | WordPress REST API |
| フロントエンド | Astro |
| UIコンポーネント | React(部分的に使用) |
| フォーム管理 | React Hook Form |
| バリデーション | Zod |
| スタイリング | Tailwind CSS |
# 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.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を有効化
});# 投稿一覧を取得
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 | カテゴリーID | categories=1,5 |
| tags | タグID | tags=10,20 |
| author | 著者ID | author=2 |
| orderby | 並び順の基準 | orderby=date |
| order | 昇順/降順 | order=asc |
| page | ページ番号 | page=2 |
| per_page | 1ページあたりの件数 | per_page=20 |
// 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 [];
}
}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>
);
}検索結果ページは動的にクエリパラメータを処理する必要があるため、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>カテゴリーやタグでフィルタリングできる機能を追加します。
// 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>
);
}// 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;
}<!-- 画像の遅延読み込み -->
<img
src={imageUrl}
alt={altText}
loading="lazy"
decoding="async"
class="w-full h-48 object-cover"
/>


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



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



わかりました!早速自分のプロジェクトに実装してみます!
はい、対応しています。ただし、日本語の場合はURLエンコードが必要です。JavaScriptのencodeURIComponent()を使用して検索クエリをエンコードすれば正しく動作します。また、WordPress側のデータベースの文字コードがUTF-8であることを確認してください。
Astroのoutput: 'hybrid'は、デフォルトで全ページを静的生成(SSG)しつつ、export const prerender = falseを指定したページのみSSR(サーバーサイドレンダリング)で動的に処理するモードです。検索結果ページのようにURLパラメータに応じて内容が変わるページに適しています。
WordPress REST APIの標準検索はタイトルと本文のみ対象です。より高度な全文検索が必要な場合は、Elasticsearchと「ElasticPress」プラグインの組み合わせや、Algoliaとの連携を検討してください。また、orderby=relevanceを設定することで、検索キーワードとの関連度順に並び替えできます。
Yoast SEOやRank MathなどのSEOプラグインはバックエンドで機能し、REST APIを通じてメタデータを取得できます。Yoast SEOの場合、?_fields=yoast_head_jsonパラメータを追加することでSEOメタデータをJSON形式で取得でき、フロントエンドのAstroで利用できます。
可能です。AstroはVercel・Netlify・Cloudflare Pagesに公式対応しています。SSRを使用する場合はそれぞれのアダプター(@astrojs/vercel等)をインストールして設定してください。環境変数はデプロイ先のダッシュボードで設定します。
本記事では、ヘッドレスCMS(WordPress)とAstroを使ったサイト内検索の実装方法を、基礎から応用まで詳しく解説しました。
prerender = falseでSSRを有効化するWithCodeで学んだWeb制作の基礎知識に、ヘッドレスCMSの技術を組み合わせれば、高速で柔軟なモダンWebサイトを構築できます。ヘッドレスCMSとモダンフロントエンドフレームワークの組み合わせは、今後ますます主流になっていきます。この記事を参考に、ぜひあなたのプロジェクトでもヘッドレスCMSを活用したサイト内検索を実装してみてください。


副業・フリーランスが主流になっている今こそ、自らのスキルで稼げる人材を目指してみませんか?
未経験でも心配することはありません。初級コースを受講される方の大多数はプログラミング未経験です。まずは無料カウンセリングで、悩みや不安をお聞かせください!
公式サイト より
今すぐ
無料カウンセリング
を予約!