🚀
スクロール連動URL更新とSEO対策を両立させるシングルページアプリケーションの実装
はじめに
モダンなポートフォリオサイトでは、スムーズなスクロール体験とSEO対策の両立が求められます。本記事では、Next.js 15を使用して、スクロールに応じてURLが変化し、各セクションが個別のURLを持ちながらも、シングルページアプリケーション(SPA)のような体験を提供する実装方法を解説します。
実装の要件
- 各セクションが独立したURLを持つ - SEO対策として重要
- スクロールに応じてURLが自動更新 - ユーザーの現在位置を反映
- ブラウザの戻る/進むボタンが機能 - 適切なナビゲーション
- 直接URLアクセスで該当セクションを表示 - ディープリンク対応
- ページリロードなしでURL変更 - スムーズなUX
アーキテクチャ概要
┌─────────────────────────────────────┐
│ メインページ (/) │
│ ┌─────────────────────────────┐ │
│ │ Intersection Observer API │ │
│ └─────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────┐ │
│ │ History API (pushState) │ │
│ └─────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────┐ │
│ │ 個別セクションページ │ │
│ │ (/about, /research, etc) │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
実装の詳細
1. スクロール監視フックの実装
// hooks/useScrollNavigation.ts
import { useEffect, useRef, useCallback, useMemo, useState } from "react";
import { useParams } from "next/navigation";
interface TopSection {
entry: IntersectionObserverEntry;
top: number;
}
export function useScrollNavigation() {
const params = useParams();
const locale = params.locale as string;
const observerRef = useRef<IntersectionObserver | null>(null);
const sectionsRef = useRef<{ id: string; element: HTMLElement }[]>([]);
const isNavigatingRef = useRef(false);
const [currentSection, setCurrentSection] = useState("hero");
// セクションのIDリスト
const sectionIds = useMemo(
() => [
"hero",
"about",
"research",
"skills",
"projects",
"blog",
"certifications",
"teaching",
"gallery",
],
[]
);
// URLを更新する関数
const updateURL = useCallback(
(sectionId: string) => {
// ナビゲーションクリックによる移動中はURL更新をスキップ
if (isNavigatingRef.current) return;
const newPath = sectionId === "hero" ? `/${locale}` : `/${locale}/${sectionId}`;
const currentPath = window.location.pathname;
// 現在のパスと異なる場合のみ更新
if (newPath !== currentPath) {
window.history.replaceState(
{ ...window.history.state, as: newPath, url: newPath },
"",
newPath
);
}
},
[locale]
);
// Intersection Observerのセットアップ
useEffect(() => {
const observerCallback: IntersectionObserverCallback = (entries) => {
let topSection: TopSection | null = null;
entries.forEach((entry) => {
if (entry.isIntersecting) {
const rect = entry.target.getBoundingClientRect();
const top = rect.top;
// ビューポートの上半分にあるセクションを優先
if (top <= window.innerHeight / 2) {
if (!topSection || top > topSection.top) {
topSection = { entry, top };
}
}
}
});
// 最も適切なセクションを更新
if (topSection !== null) {
const sectionData = topSection as TopSection;
const targetElement = sectionData.entry.target as HTMLElement;
if (targetElement.id && targetElement.id !== currentSection) {
setCurrentSection(targetElement.id);
updateURL(targetElement.id);
}
}
};
observerRef.current = new IntersectionObserver(
observerCallback,
{
threshold: [0.1, 0.5, 0.9],
rootMargin: "-10% 0px -10% 0px",
}
);
// セクション要素を監視
sectionsRef.current.forEach(({ element }) => {
observerRef.current?.observe(element);
});
return () => {
observerRef.current?.disconnect();
};
}, [currentSection, updateURL]);
return { currentSection, scrollToSection };
}
2. SEO用の個別ページ実装
各セクション用のページを作成し、メインページへリダイレクト:
// app/[locale]/about/page.tsx
import { redirect } from "next/navigation";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return {
title: "About Me | Ryo Shin Portfolio",
description: "Learn about my background in AI research and language education",
alternates: {
canonical: `https://ryosh.in/${locale}/about`,
},
};
}
export default async function AboutPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
redirect(`/${locale}#about`);
}
3. 動的インポートによるパフォーマンス最適化
// app/[locale]/page.tsx
import dynamic from "next/dynamic";
// 重要なセクションは通常インポート
import HeroSection from "@/components/HeroSection";
import AboutSection from "@/components/AboutSection";
// その他のセクションは動的インポート
const ResearchSection = dynamic(() => import("@/components/ResearchSection"), {
loading: () => <SectionSkeleton />,
});
const SkillsSection = dynamic(() => import("@/components/SkillsSection"), {
loading: () => <SectionSkeleton />,
});
// ... 他のセクション
export default function Home() {
return (
<main>
<HeroSection />
<AboutSection />
<ResearchSection />
<SkillsSection />
{/* ... */}
</main>
);
}
4. ナビゲーション実装
const Navigation = () => {
const { currentSection, scrollToSection } = useScrollNavigation();
const navigateTo = (sectionId: string) => {
scrollToSection(sectionId);
// スムーズスクロール後にURLを更新
};
return (
<nav>
{sections.map((section) => (
<button
key={section.id}
onClick={() => navigateTo(section.id)}
className={currentSection === section.id ? "active" : ""}
>
{section.label}
</button>
))}
</nav>
);
};
SEO最適化のポイント
1. サイトマップの生成
// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
const locales = ["ja", "en", "zh"];
const sections = ["about", "research", "skills", "projects", "blog", "certifications", "teaching", "gallery"];
const urls: MetadataRoute.Sitemap = [];
locales.forEach((locale) => {
// メインページ
urls.push({
url: `https://ryosh.in/${locale}`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1,
});
// 各セクション
sections.forEach((section) => {
urls.push({
url: `https://ryosh.in/${locale}/${section}`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
});
});
});
return urls;
}
2. 構造化データの実装
// components/StructuredData.tsx
export default function StructuredData({ locale }: { locale: string }) {
const structuredData = {
"@context": "https://schema.org",
"@type": "Person",
name: "梁震 (Ryo Shin)",
url: `https://ryosh.in/${locale}`,
sameAs: [
"https://github.com/yourusername",
"https://linkedin.com/in/yourusername",
],
jobTitle: "AI Researcher & Japanese Language Teacher",
worksFor: {
"@type": "Organization",
name: "EastLinker Inc.",
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
);
}
パフォーマンス考慮事項
1. スクロールイベントの最適化
// デバウンス処理を追加
const debounce = (func: Function, wait: number) => {
let timeout: NodeJS.Timeout;
return (...args: any[]) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
};
// スクロール処理にデバウンスを適用
const debouncedUpdateURL = useMemo(
() => debounce(updateURL, 100),
[updateURL]
);
2. プリフェッチング戦略
// 次のセクションを事前読み込み
const prefetchNextSection = (currentIndex: number) => {
const nextIndex = currentIndex + 1;
if (nextIndex < sections.length) {
const nextSection = sections[nextIndex];
// 動的インポートの事前読み込み
import(`@/components/${nextSection.component}`);
}
};
測定結果
Core Web Vitals
LCP (Largest Contentful Paint): 1.2s
FID (First Input Delay): < 10ms
CLS (Cumulative Layout Shift): 0.002
SEOスコア
Lighthouse SEO Score: 100
- 全セクションが個別URLを持つ
- 適切なメタデータ
- 構造化データの実装
- サイトマップの提供
トラブルシューティング
1. ブラウザの戻るボタンが期待通り動作しない
// popstateイベントのハンドリング
useEffect(() => {
const handlePopState = (event: PopStateEvent) => {
const path = window.location.pathname;
const sectionId = path.split("/").pop() || "hero";
scrollToSection(sectionId);
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [scrollToSection]);
2. 初回アクセス時のスクロール位置
// URLハッシュからセクションIDを取得
useEffect(() => {
const hash = window.location.hash.slice(1);
if (hash && sectionIds.includes(hash)) {
setTimeout(() => {
scrollToSection(hash);
}, 100);
}
}, []);
まとめ
この実装により、以下を実現できました:
- SEO最適化 - 各セクションが独立したURLとメタデータを持つ
- スムーズなUX - ページリロードなしでのナビゲーション
- 適切な履歴管理 - ブラウザの戻る/進むボタンの動作
- パフォーマンス - 動的インポートによる初期ロードの最適化
SPAの利便性とMPAのSEO効果を両立させることで、ユーザーにも検索エンジンにも優しいサイトを構築できます。
Discussion