BLOG

ブログ

WordPressをヘッドレスCMS化しAstroでブログ構築!全文検索機能の実装方法を紹介

こちらは、Mavs Advent Calendar 2024 23日目の記事です🐺!

🌲🌲🌲

ハコザキです!

はじめに

2024年現在、サイト制作の技術選定において「Astro」という選択肢が注目を集めています。
一方、長年使われてきたWordPressをヘッドレスCMSとして利用することで、コンテンツ管理の利便性をそのまま活かしつつ、最新のフロントエンド技術を取り入れた柔軟なサイト構築が可能です。

今回は、WordPressをヘッドレスCMSとして活用し、「Astro」でブログを構築してみました。
また、検索機能も実装してみましたのであわせてご紹介します。

実際にこのようなイメージで、Vercelでホスティングしております。
https://astro-wp-blog.vercel.app/

コードだけ見たい!という方は以下のGitHubリポジトリを参照してください!
https://github.com/p-t-a-p-1/astro-wp-blog

Astroとは?

Astroは静的サイト生成に特化した、 現代のWeb制作ではよく使われているWebフレームワークです。

シンプルな記述が特徴で、ビルド時にjsをなるべく取り除いて必要な部分だけJavaScriptを残すため、 ページを開いてから表示されるまでの速度がかなり速いです…!

当たり前ですが、Next.jsの場合はReact.js、Nuxtの場合はVue.jsをプロジェクト単位で入れる必要があります。 AstroはReactやVueをコンポーネント単位で利用することができます。 (1プロジェクト内で分けて書くことはあまりないと思いますが…!)

Astro独自の記述でもコンポーネント実装をすることはできますが、より複雑な実装が必要になった際には ReactやVueなどで実装したコンポーネントも利用できるといったこともできます。
また、Astroはアイランドアーキテクチャを採用していることも大きな特徴です。
公式サイトでわかりやすく解説してますので興味のある方はどうぞ!

Astro公式サイト

ブログサイトの仕様

ベースはAstro、コンポーネントについてはReactコンポーネントで実装しました。
スタイリングはshadcn/ui( + Tailwind CSS)で組んでおります。
また、検索機能はReact hook form + Zodでフォームを実装しました。

事前準備

WordPressが構築済みで、WordPress REST API(WP REST API)が実行できれば大丈夫です!
※ローカルWPでも動くのであればokです!

WP REST APIとは、WordPress上の特定のURLにアクセスすることでWordPressに登録された様々な情報を参照・更新することができる仕組みになります。

今回は会社のWordPressから記事を取得しようと思います。
※弊社はすでにWordPressをヘッドレスCMS化し、Nuxt.jsのSSGでサイトを構築しております。

環境構築

Astro

公式サイト参考に初期構築をしてください。
npm run devで初期画面が表示されればokです。
https://docs.astro.build/en/install-and-setup

その後、shadcn/ui( + Tailwind CSS)をインストールしてください。
公式サイトに丁寧にまとめられてましたので↓で進めるとわかりやすいです。
https://ui.shadcn.com/docs/installation/astro

なお、今回は以下のコンポーネントを追加してください。

npx shadcn@latest add button input form

Astro内でReactを使えるようにする

以下のコマンドでReactを使えるようにします。

npx astro add react

質問に答えることで自動で以下のように設定ファイルが更新されると思います。

// @ts-check
import { defineConfig } from "astro/config";

import react from "@astrojs/react";

import tailwind from "@astrojs/tailwind";

// https://astro.build/config
export default defineConfig({
	integrations: [
		react(),
		tailwind({
			applyBaseStyles: false,
		}),
	],
});

WP REST API レスポンスの型情報

以下のコマンドでWP REST APIのレスポンスの型情報をインストールします。

npm install wp-types --save-dev

環境変数にWP REST APIのベースURLを設定

プロジェクト直下に.envファイルを作成し、WP REST APIのベースURLを記載します。

PUBLIC_WP_API=https://〇〇/wp-json/wp/v2

一覧ページ

src/pages/blog/index.astroを作成し以下を記述します。
〇〇〇〇にはそれぞれのWP REST APIの記事一覧用エンドポイントを記載してください。
カスタム投稿タイプに対応しております。


Astroの基本構文ですが、コードフェンスと呼ばれる --- の間にJSを記載することができます。

---
const name = "Astro";
---
<div>
  <h1>Hello {name}!</h1>  <!-- <h1>Hello Astro!</h1> を出力 -->
</div>


また、HTML部分についてはLayoutで囲まないと文字化けして正常に表示されないので気をつけてください。
pages配下にastroファイルを作成することで自動でルーティングされるファイルルーティングシステムになっているため、pages内はtsxでなくastroファイルであることに注意してください。
Reactコンポーネントファイルはcomponents/に置くようにしたほうが良いです。

---
import Layout from "@/layouts/Layout.astro";
import BlogCard from "@/components/elements/BlogCard";

// 記事の取得
const fetchAllBlogPosts = async (): Promise<blogPost[]> => {
	return await fetch(
		`〇〇〇〇?_embed&author=3&per_page=100`,
	).then((response) => response.json());
};

const articles = await fetchAllBlogPosts();
---

<Layout>
	<section class="container grid items-center gap-6 pb-8 pt-6 md:py-10 mx-auto">
		<h1 class="text-2xl font-bold">記事一覧</h1>
		<div class="grid grid-cols-1 gap-4 lg:grid-cols-4 lg:gap-4">
			{articles.map((article) => {
				return (
					<BlogCard
						url={`/blog/${article.slug}`}
						imgSrc={article._embedded['wp:featuredmedia'][0].source_url}
						title={article.title.rendered}
					/>
				)
			})}
		</div>
	</section>
</Layout>

参考までに、BlogCardコンポーネントはReactコンポーネントです。
src/components/elements/BlogCard/index.tsxを作成し以下を記述します。

export default function BlogCard({
	imgSrc,
	url,
	title,
	description,
}: {
	imgSrc: string;
	url: string;
	title: string;
	description?: string;
}) {
	return (
		<article className="flex flex-col rounded-lg border border-gray-100 bg-background hover:bg-accent p-2 shadow-sm transition hover:shadow-lg sm:p-6">
			<a href={url}>
				<img
					src={imgSrc}
					alt={title}
					className="w-full h-48 object-cover rounded-lg aspect-auto"
				/>
				<h3 className="mt-4 text-lg font-medium text-secondary-foreground">
					{title}
				</h3>
			</a>

			<a
				href={url}
				className="group mt-2 inline-flex items-center gap-1 text-sm font-medium text-blue-600"
			>
				More
				<span
					aria-hidden="true"
					className="block transition-all group-hover:ms-0.5 rtl:rotate-180"
				>
					→
				</span>
			</a>
		</article>
	);
}

この時点で以下のように表示できていればokです。
ヘッダーについては詳細の実装はこの記事では紹介しません…!
以下ソースコードになります。
https://github.com/p-t-a-p-1/astro-wp-blog/blob/main/src/components/layouts/Header/index.tsx

詳細ページ

src/pages/blog/[slug].astroを作成し以下の記述します。
一覧と型やfetch周りで重複してますがわかりやすく1ファイルで表現しているだけです…!!

---
import Layout from "@/layouts/Layout.astro";

export type blogPost = WP_REST_API_Post & {
	_embedded: {
		"wp:featuredmedia": {
			source_url: string;
		}[];
	};
};

export const fetchAllBlogPosts = async (): Promise<blogPost[]> => {
	return await fetch(
		`〇〇〇〇?_embed&author=3&per_page=100`,
	).then((response) => response.json());
};

export async function getStaticPaths() {
	return (await fetchAllBlogPosts()).map((post) => {
		return {
			params: {
				slug: post.slug,
			},
			props: {
				post,
			},
		};
	});
}

const { post } = Astro.props;
---

<Layout>
	<section class="container grid items-center gap-6 pb-8 pt-6 md:py-10 mx-auto max-w-3xl">
		<h1 class="text-2xl font-bold">{post.title.rendered}</h1>
		<div class="blogContent" set:html={post.content.rendered}></div>
		<div class="flex justify-center">
			<a href="/blog" class="text-blue-500">
				一覧に戻る
			</a>
		</div>
  </section>
</Layout>

<style>
	.blogContent {
		line-height: 1.75;
	}
	.blogContent :global(h2) {
		margin-top: 2rem;
		font-size: 1.5rem;
		font-weight: bold;
	}
	.blogContent :global(h3) {
		margin-top: 1.5rem;
		font-size: 1.25rem;
		font-weight: bold;
	}
	.blogContent :global(h4) {
		margin-top: 1rem;
		font-size: 1rem;
		font-weight: bold;
	}
	.blogContent :global(h5) {
		font-size: 0.875rem;
	}
	.blogContent :global(h6) {
		font-size: 0.75rem;
	}
	.blogContent :global(p) {
		margin-top: 1rem;
		margin-bottom: 1rem;
	}
	.blogContent :global(ul) {
		margin-top: 1rem;
		margin-bottom: 1rem;
	}
	.blogContent :global(ol) {
		margin-top: 1rem;
		margin-bottom: 1rem;
	}
	.blogContent :global(li) {
		margin-top: 0.5rem;
		margin-bottom: 0.5rem;
	}
	.blogContent :global(a) {
		color: #3182ce;
	}
	.blogContent :global(a:hover) {
		color: #2c5282;
	}
	.blogContent :global(img) {
		max-width: 100%;
	}
	.blogContent :global(figure) {
		max-width: 100%;
	}
	.blogContent :global(pre) {
		background-color: #f6f8fa;
		padding: 1rem;
		border-radius: 0.5rem;
		max-width: 100%;
		white-space: pre-wrap;
	}
	.blogContent :global(code) {
		background-color: #f6f8fa;
		padding: 0.25rem;
		border-radius: 0.25rem;
	}
	.blogContent :global(blockquote) {
		border-left: 0.25rem solid #3182ce;
		padding-left: 1rem;
	}
</style>

重要な箇所のみ説明します。

ブログの詳細ページは、getStaticPathsを利用してビルド時にページ生成を行います。
getStaticPathsは静的サイトジェネレーターとして使用する、生成する静的ページのパスの一覧を取得するための関数です。getStaticPaths内で記事一覧を取得し、一つ一つパスを生成します。

set:htmlディレクティブを使用し、WordPressのブロック本文をHTML要素のまま当てています。
また、 :global() セレクタを使用することで、コンポーネントの子要素にスタイルを適用することができます。
上記の例だとblogContentクラスの中にあるタグに対しスタイルを適用することができます。
HTMLのコンテンツがAstroの外にある場合などによく使われます。

実際に遷移してみて以下のように表示できていればokです。

ここまでで、一般的なブログの一覧・詳細ページが完成しました。
続いて、検索機能を実装してみます。

検索機能

フォームについては、React hook form + Zodで実装します。
まずは必要なライブラリをインストールします。

npm install react-hook-form zod @hookform/resolvers

フォームコンポーネント

src/components/elements/SearchButton/index.tsxを作成し、以下を記述します。
Input要素に入力された文字列をもとに、ページ遷移を行います。

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Icons } from "../Icons";
import { Form, FormField } from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const formSchema = z.object({
	q: z.string().nonempty(),
});

export default function SearchButton() {
	const form = useForm<z.infer<typeof formSchema>>({
		resolver: zodResolver(formSchema),
		defaultValues: {
			q: "",
		},
	});

	function onSubmit(values: z.infer<typeof formSchema>) {
		if (!values.q) return;
		// ページ遷移
		window.location.href = `/blog/search?q=${values.q}`;
	}

	return (
		<Form {...form}>
			<form
				onSubmit={form.handleSubmit(onSubmit)}
				className="flex w-full max-w-sm items-center space-x-2"
			>
				<FormField
					control={form.control}
					name="q"
					render={({ field }) => (
						<Input type="text" placeholder="サイト内検索" {...field} />
					)}
				/>
				<Button color="primary">
					<Icons.search fill="currentColor" />
				</Button>
			</form>
		</Form>
	);
}

部分的SSR

検索ページのみSSRでクエリパラメータによって、
WP REST APIを実行することで検索機能を実現したいと思います。

Astroのデフォルト設定は基本的に全ページビルド時に静的化を行うようになっております。
Astroの機能で部分的にSSR、つまりサーバーリクエストを行い都度サーバー側でHTML生成し表示することもできます。
具体的にはpages内のastroファイルに以下を記述する必要があります。

export const prerender = false;

On-demand rendering | Docs
https://docs.astro.build/ja/guides/on-demand-rendering

また、WP REST APIでは一覧取得のエンドポイントにsearch=〇〇をつけることでタイトル・本文含めた文字列から検索を行います。
※タイトルだけ検索してほしい場合はtitle=〇〇にする必要があります。

それでは、実際の実装手順についてご紹介します。
src/pages/blog/search.astroを作成し以下を記述します。

---
import Layout from "@/layouts/Layout.astro";
import type { blogPost } from "../index.astro";
import BlogCard from "@/components/elements/BlogCard";

export const fetchSearchBlogPosts = async (
	keyword: string,
): Promise<blogPost[]> => {
	// keywordが空文字の場合は全件取得
	const search = keyword ? `&search=${keyword}` : "";

	return await fetch(
		`〇〇〇〇?_embed&author=3&per_page=100${search}`,
	).then((response) => response.json());
};

// 検索
let q = "";
try {
	if (Astro.request.method === "GET") {
		const url = new URL(Astro.request.url);
		const params = new URLSearchParams(url.search);
		q = params.get("q") || "";
	}
} catch (error) {
	console.error(error);
}

const articles = await fetchSearchBlogPosts(q);

export const prerender = false;
---

<Layout>
	<section class="container grid items-center gap-6 pb-8 pt-6 md:py-10 mx-auto">
		<h1 class="text-2xl font-bold">「{q}」の検索結果</h1>
		<div class="grid grid-cols-1 gap-4 lg:grid-cols-4 lg:gap-4">
			{articles.length > 0 ? (
				articles.map((article) => (
					<BlogCard
						url={`/blog/${article.slug}`}
						imgSrc={article._embedded['wp:featuredmedia'][0].source_url}
						title={article.title.rendered}
					/>
				))
			) : (
				<div class="text-center">
					<p>記事が見つかりませんでした。</p>
				</div>
			)}
		</div>
	</section>
</Layout>

fetchSearchBlogPosts関数を実行し、クエリパラメータ情報をもとに
WP REST APIのエンドポイントを作成しfetchします。
prerenderをfalseにすることで/blog/searchはSSRで処理するように定義することも重要です。

これで検索機能を実現することができました!!

なお、Vercelへのデプロイについては公式ドキュメントの方でサポートされていました。
こちらに沿って作業するだけでデプロイできました!簡単です!!

AstroサイトをVercelにデプロイする | Docs
https://docs.astro.build/ja/guides/deploy/vercel/

おわり

今回はWordPressをヘッドレスCMS化し、Astroを使ってブログを構築する方法を紹介しました。
従来のWordPressの利便性を活かしつつ、モダンなフロントエンド技術を取り入れることで、より高速で柔軟なWebサイトを構築できることが実感できました。

ブログサイトで検索機能を実装することはよくあります。実際にやってみましたがそこまで大変ではなかったです。

WordPressのヘッドレス化とAstroの組み合わせは、移行の手間を抑えながらも、静的サイトならではの高速なパフォーマンスを実現できる非常に有力な選択肢だと感じました。
今後も、この構成を活用したさらなる応用や新しい機能の実装に挑戦していきたいと思います!!

なお、今回ご紹介したソースコードについては以下のリポジトリにありますので興味ある方はぜひ見てみてください!!
https://github.com/p-t-a-p-1/astro-wp-blog

RELATED ARTICLE

  • この記事を書いた人
  • 最新の記事