Next.js × ヘッドレスWordPressで構築した、スクロール連動動画サイトの実装設計
SIMCODE公式サイトをリニューアルしました。今回のリニューアルでは、見た目を新しくするだけでなく、技術的にもいくつかの挑戦を入れています。特に大きなポイントは、次の2つです。
- スクロールに合わせて動画が進むトップページ演出
- WordPressをヘッドレスCMSとして使い、フロントエンドをNext.jsで構築する設計
この記事では、今回のリニューアルで採用した構成や実装方針を、技術的な視点から整理します。
今回のサイト構成
今回のサイトは、以下のような構成を想定しています。
Frontend:
Next.js
CMS:
WordPress
Content:
固定ページ、ブログ記事、カテゴリ、タグ、アイキャッチ画像
Hosting:
Next.jsを動かせるホスティング環境
考え方としては、WordPressを「表示するためのテーマ」として使うのではなく、 記事やコンテンツを管理するCMSとして使います。表側のデザインやUIはNext.jsで構築し、WordPressの記事データはREST API経由で取得します。
WordPress
↓ REST API
Next.js
↓
ユーザーに表示
WordPress公式のREST APIでは、投稿一覧を GET /wp/v2/posts で取得できます。 カテゴリ、タグ、メディアなどもREST APIの対象になります。
参考: WordPress REST API Handbook - Posts
なぜWordPressテーマではなく、ヘッドレス構成にしたのか
既存サイトにはWordPressブログがありました。そのため、記事本文、カテゴリ、タグ、アイキャッチ画像、過去URLなどの資産をそのまま活かす必要があります。一方で、今回の新デザインでは、トップページにスクロール連動動画を使い、下層ページもNext.jsのコンポーネントで統一したいという要件がありました。
WordPressテーマとして実装することも不可能ではありません。 ただし、今回のようにフロントエンド側で細かいUI制御や演出を入れたい場合、WordPressテーマに寄せるより、Next.jsで表示側を分離した方が設計しやすくなります。
今回の判断理由は、以下です。
- 既存ブログ記事はWordPressに残せる
- 管理画面はこれまで通り使える
- 表示側のUIはNext.jsで自由に作れる
- ページごとのコンポーネント分割がしやすい
- SEOメタ情報をページ単位・記事単位で制御しやすい
- 今後のプロダクトページやNews表示にも拡張しやすい
つまり、WordPressを捨てるのではなく、 WordPressの得意な管理画面だけを活かす構成です。
スクロールに合わせて動画が進む仕組み
今回のトップページでは、背景動画をただ自動再生するのではなく、 ユーザーのスクロール量に合わせて動画の再生位置を変える設計にしています。通常の背景動画は、ページの状態とは関係なく再生され続けます。一方、スクロール連動型では、スクロール位置を動画の再生時間に変換します。
基本の考え方は以下です。
スクロール進捗率 = 現在のスクロール位置 / スクロール可能な高さ
動画の再生位置 = 動画の総尺 × スクロール進捗率
HTMLの動画要素では、currentTime に秒数を代入することで再生位置を変更できます。 MDNでは、HTMLMediaElement.currentTime は現在の再生時刻を秒単位で表し、この値を変更すると指定時刻へシークできると説明されています。
参考: MDN - HTMLMediaElement: currentTime property
実装イメージ
実装の骨子は、以下のような形です。
"use client";
import { useEffect, useRef } from "react";
export function ScrollSyncedVideo() {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
let ticking = false;
const updateVideoTime = () => {
const scrollTop = window.scrollY;
const scrollHeight =
document.documentElement.scrollHeight - window.innerHeight;
if (scrollHeight <= 0 || !Number.isFinite(video.duration)) {
ticking = false;
return;
}
const progress = Math.min(Math.max(scrollTop / scrollHeight, 0), 1);
video.currentTime = video.duration * progress;
ticking = false;
};
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateVideoTime);
ticking = true;
}
};
video.addEventListener("loadedmetadata", updateVideoTime);
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
video.removeEventListener("loadedmetadata", updateVideoTime);
window.removeEventListener("scroll", onScroll);
};
}, []);
return (
<video
ref={videoRef}
muted
playsInline
preload="auto"
className="fixed inset-0 h-full w-full object-cover"
>
<source src="/assets/videos/top-background.mp4" type="video/mp4" />
</video>
);
}
ここで重要なのは、スクロールイベントのたびに直接重い処理を走らせないことです。scroll イベントは短時間に大量発火します。 そのため、requestAnimationFrame を使ってブラウザの描画タイミングに合わせて更新する方が安定します。また、動画の duration はメタデータが読み込まれるまで確定しないため、 loadedmetadata 後に初期同期を行うようにしています。
実装時に注意したポイント
スクロール連動動画は見た目のインパクトがありますが、実装面ではいくつか注意点があります。
1. duration が取得できる前に処理しない
動画の長さがまだ分からない状態で video.duration を使うと、NaN になる可能性があります。そのため、以下のようにチェックします。
if (!Number.isFinite(video.duration)) {
return;
}
2. スクロール量は0〜1に丸める
スクロール進捗率が0未満や1超えにならないようにします。
const progress = Math.min(Math.max(scrollTop / scrollHeight, 0), 1);
3. 動画は固定背景として扱う
今回の演出では、動画そのものをスクロールさせるのではなく、背景として固定しています。
video:
position: fixed;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
その上に薄いオーバーレイを敷き、前景コンテンツを重ねています。
video layer z-index: 0
overlay layer z-index: 10
content layer z-index: 20
4. 動画を主役にしすぎない
企業サイトでは、演出よりも情報の伝達が優先です。そのため、動画の上に薄い白系オーバーレイを置き、文字やカードの可読性を確保しています。
<div className="fixed inset-0 z-10 bg-[rgba(245,247,250,0.44)]" />
動画はあくまで世界観をつくる背景です。 コピー、プロダクト、会社の考え方が読めることを優先しています。
WordPress REST APIからブログ記事を取得する
今回の構成では、既存ブログ記事をNext.js側で表示するために、WordPress REST APIを使う想定です。投稿一覧は、以下のようなエンドポイントから取得できます。
https://example.com/wp-json/wp/v2/posts
WordPress REST APIの投稿エンドポイントでは、投稿一覧を取得でき、クエリパラメータで取得条件を制御できます。
参考: WordPress REST API Handbook - Posts
実装イメージは以下です。
export type WordPressPost = {
id: number;
slug: string;
date: string;
modified: string;
title: {
rendered: string;
};
excerpt: {
rendered: string;
};
content: {
rendered: string;
};
_embedded?: {
"wp:featuredmedia"?: Array<{
source_url: string;
alt_text?: string;
}>;
"wp:term"?: Array<
Array<{
id: number;
name: string;
slug: string;
taxonomy: string;
}>
>;
};
};
const WP_API_BASE = process.env.NEXT_PUBLIC_WORDPRESS_API_URL;
export async function getPosts(limit = 10): Promise<WordPressPost[]> {
if (!WP_API_BASE) return [];
const res = await fetch(
`${WP_API_BASE}/posts?_embed&per_page=${limit}&orderby=date&order=desc`,
{
next: {
revalidate: 300,
},
}
);
if (!res.ok) {
throw new Error("Failed to fetch WordPress posts");
}
return res.json();
}
ここでは _embed を付けることで、アイキャッチ画像やカテゴリ情報など、関連情報も取得しやすくしています。また、Next.jsでは fetch に next.revalidate を指定することで、一定間隔でデータを再検証できます。
WordPress記事をNext.js用に整形する
WordPress REST APIのレスポンスは、そのまま表示用コンポーネントに渡すには少し扱いづらい場合があります。そのため、Next.js側で記事カード用の形に変換します。
export type BlogPostCard = {
id: number;
title: string;
slug: string;
href: string;
category: string;
date: string;
excerpt: string;
image?: string;
};
export function mapWordPressPostToCard(post: WordPressPost): BlogPostCard {
const terms = post._embedded?.["wp:term"]?.flat() ?? [];
const category =
terms.find((term) => term.taxonomy === "category")?.name ?? "Blog";
const image = post._embedded?.["wp:featuredmedia"]?.[0]?.source_url;
return {
id: post.id,
title: stripHtml(post.title.rendered),
slug: post.slug,
href: `/blog/${post.slug}`,
category,
date: formatDate(post.date),
excerpt: stripHtml(post.excerpt.rendered),
image,
};
}
WordPress本文にはHTMLが含まれるため、タイトルや抜粋をカードで使う場合はHTMLタグを取り除く処理を入れます。
export function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, "").trim();
}
本番運用では、HTMLエンティティのデコードや、抜粋文字数の調整も入れるとより扱いやすくなります。
ブログ一覧と記事詳細のルーティング
ブログは以下のルーティングを想定しています。
/blog
/blog/category/[...slug]
/blog/[slug]
カテゴリページでは、既存WordPressのカテゴリ階層を維持するために、catch-all routeを使えるようにしておきます。
/blog/category/digitalmarketing/sns/
のようなURLにも対応しやすくするためです。既存URLのSEO評価を落とさないためには、可能な限りURL構造を維持することが重要です。 もしURLを変更する場合は、301リダイレクトを設計します。
トップページのNewsは記事カードにしない
トップページには最新記事を表示しますが、ブログカードとして大きく見せるのではなく、 Newsとして控えめに表示します。表示するのは、次の3つだけです。
- 日付
- カテゴリ
- 記事タイトル
最新5件を表示し、タイトルから記事詳細へリンクします。
export function mapPostToNewsItem(post: WordPressPost) {
const terms = post._embedded?.["wp:term"]?.flat() ?? [];
const category =
terms.find((term) => term.taxonomy === "category")?.name ?? "Blog";
return {
date: formatDate(post.date),
category,
title: stripHtml(post.title.rendered),
href: `/blog/${post.slug}`,
};
}
トップページでは、プロダクトや会社紹介が主役です。 そのため、Newsは「更新されている会社感」を出すための補助的な導線として設計しています。
記事詳細ページではWordPress本文をどう扱うか
記事詳細ページでは、WordPressの本文HTMLをNext.jsで表示する必要があります。もっとも単純には、以下のように dangerouslySetInnerHTML を使います。
type ArticleHtmlBodyProps = {
html: string;
};
export function ArticleHtmlBody({ html }: ArticleHtmlBodyProps) {
return (
<article
className="article-content"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
ただし、外部入力のHTMLを表示するため、セキュリティ面の注意が必要です。WordPress管理画面から投稿された本文であっても、埋め込みHTML、script、iframeなどの扱いは運用ルールを決めておくべきです。記事本文用のCSSは、.article-content 配下に閉じ込めます。
.article-content h2 {
margin-top: 3rem;
border-left: 4px solid #2563eb;
padding-left: 1.25rem;
font-size: 2rem;
line-height: 1.35;
font-weight: 700;
}
.article-content p {
margin-top: 1.25rem;
color: #475569;
line-height: 1.95;
}
.article-content img {
margin: 2rem 0;
border-radius: 20px;
}
こうしておくと、WordPress側の本文HTMLを使いながら、見た目はNext.js側のデザインに統一できます。
SEO設定はNext.js側で制御する
今回の構成では、SEOメタ情報はNext.js側で制御します。Next.js App Routerでは、静的な metadata や動的な generateMetadata を使って、ページごとのtitle、description、OG画像などを設定できます。
参考: Next.js Docs - Metadata and OG images
記事詳細ページでは、WordPressから取得した記事情報を使ってメタ情報を生成します。
import type { Metadata } from "next";
type Props = {
params: Promise<{
slug: string;
}>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
return {
title: "記事が見つかりません | SIMCODE",
};
}
return {
title: `${stripHtml(post.title.rendered)} | SIMCODE`,
description: stripHtml(post.excerpt.rendered),
openGraph: {
title: stripHtml(post.title.rendered),
description: stripHtml(post.excerpt.rendered),
images: [
{
url:
post._embedded?.["wp:featuredmedia"]?.[0]?.source_url ??
"/assets/images/ogp-default.png",
},
],
},
};
}
記事タイトル、抜粋、アイキャッチ画像を使って、検索結果やSNS共有時の表示を整える設計です。
パフォーマンス面で意識したこと
今回のように動画や画像が多いサイトでは、パフォーマンスにも注意が必要です。
1. 動画は必要以上に重くしない
スクロール連動動画は、ファイルサイズが大きすぎると初期表示や操作感に影響します。そのため、動画はWeb表示向けに圧縮し、解像度やビットレートを調整する必要があります。
2. 画像はNext.js側で最適化しやすい構造にする
静的画像は public 配下に整理し、WordPressのアイキャッチはURLで取得する方針にします。将来的には、画像ドメインの設定や、外部画像を扱うための next.config 設定も必要になります。
3. WordPress APIは毎回取りに行きすぎない
ブログ一覧や記事詳細で毎回WordPress APIにアクセスすると、表示速度やWordPress側の負荷に影響します。そのため、Next.jsの再検証機能を使い、一定時間ごとにデータを更新する方針にします。
実装で分けた主なコンポーネント
今回のようなサイトでは、ページ単位でコードを書くのではなく、UIを部品化しておくことが重要です。想定している主なコンポーネントは以下です。
Header
Footer
HeroSection
NewsSection
ProductCard
InfoCard
FinalCtaSection
BlogHeroSection
BlogPostCard
BlogSidebar
ArticleHero
ArticleBody
ArticleSidebar
ArticleBottomCta
固定ページの文言は、まずはローカルのTSオブジェクトにまとめています。
lib/site/topPageContent.ts
lib/site/aboutPageContent.ts
lib/site/productsPageContent.ts
lib/site/companyPageContent.ts
lib/site/contactPageContent.ts
ブログについては、WordPress APIから取得するための層を別に用意します。
lib/wordpress/blog.ts
こうすることで、将来的にCMSの仕様が変わっても、表示コンポーネントへの影響を小さくできます。
今回の構成で得られるメリット
デザインの自由度が高い
Next.js側でUIを管理するため、トップページの動画演出や下層ページのカードUI、ブログ詳細の目次やCTAなどを柔軟に設計できます。
WordPressの記事資産を活かせる
既存ブログ記事をWordPressに残したまま、新しいデザインで表示できます。
更新運用を大きく変えずに済む
記事の投稿や編集は、これまで通りWordPress管理画面で行えます。
SEO設計を細かく制御できる
Next.js側でページごとのメタ情報、OGP、URL設計、内部リンクを整理できます。
今後の拡張がしやすい
Products、News、Blog、Contactなどをコンポーネント単位で拡張できます。
まとめ
今回のSIMCODE公式サイトリニューアルでは、Next.jsをフロントエンドに採用し、WordPressをヘッドレスCMSとして活用する構成にしました。トップページでは、スクロール量を動画の currentTime に反映することで、ページを読む動きと映像の進行が連動する体験を実装しています。
また、既存WordPressブログはREST APIで取得し、Next.js側のブログ一覧・記事詳細ページとして再設計しました。大切にしたのは、技術を見せるための技術ではなく、会社の思想やプロダクトの価値が伝わるための技術設計です。Webサイトは、見た目を整えるだけではなく、コンテンツ管理、SEO、表示速度、更新運用まで含めて設計することで、長く使える事業基盤になります。
SIMCODEでは、これからもマーケティングと開発を横断しながら、事業に直結し、使われ続けるプロダクトを一つひとつ丁寧に形にしていきます。