スクロール連動URL更新とSEO対策を両立させるシングルページアプリケーションの実装

# はじめに

モダンなポートフォリオサイトでは、スムーズなスクロール体験とSEO対策の両立が求められます。本記事では、Next.js 15を使用して、スクロールに応じてURLが変化し、各セクションが個別のURLを持ちながらも、シングルページアプリケーション（SPA）のような体験を提供する実装方法を解説します。

## 実装の要件

1. **各セクションが独立したURLを持つ** - SEO対策として重要
2. **スクロールに応じてURLが自動更新** - ユーザーの現在位置を反映
3. **ブラウザの戻る/進むボタンが機能** - 適切なナビゲーション
4. **直接URLアクセスで該当セクションを表示** - ディープリンク対応
5. **ページリロードなしでURL変更** - スムーズなUX

## アーキテクチャ概要

```
┌─────────────────────────────────────┐
│         メインページ (/)             │
│  ┌─────────────────────────────┐   │
│  │  Intersection Observer API   │   │
│  └─────────────────────────────┘   │
│           ↓                         │
│  ┌─────────────────────────────┐   │
│  │    History API (pushState)   │   │
│  └─────────────────────────────┘   │
│           ↓                         │
│  ┌─────────────────────────────┐   │
│  │  個別セクションページ        │   │
│  │  (/about, /research, etc)    │   │
│  └─────────────────────────────┘   │
└─────────────────────────────────────┘
```

## 実装の詳細

### 1. スクロール監視フックの実装

```tsx
// 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用の個別ページ実装

各セクション用のページを作成し、メインページへリダイレクト：

```tsx
// 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. 動的インポートによるパフォーマンス最適化

```tsx
// 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. ナビゲーション実装

```tsx
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. サイトマップの生成

```tsx
// 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. 構造化データの実装

```tsx
// 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. スクロールイベントの最適化

```tsx
// デバウンス処理を追加
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. プリフェッチング戦略

```tsx
// 次のセクションを事前読み込み
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. ブラウザの戻るボタンが期待通り動作しない

```tsx
// 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. 初回アクセス時のスクロール位置

```tsx
// URLハッシュからセクションIDを取得
useEffect(() => {
  const hash = window.location.hash.slice(1);
  if (hash && sectionIds.includes(hash)) {
    setTimeout(() => {
      scrollToSection(hash);
    }, 100);
  }
}, []);
```

## まとめ

この実装により、以下を実現できました：

1. **SEO最適化** - 各セクションが独立したURLとメタデータを持つ
2. **スムーズなUX** - ページリロードなしでのナビゲーション
3. **適切な履歴管理** - ブラウザの戻る/進むボタンの動作
4. **パフォーマンス** - 動的インポートによる初期ロードの最適化

SPAの利便性とMPAのSEO効果を両立させることで、ユーザーにも検索エンジンにも優しいサイトを構築できます。

## 参考リンク

- [MDN - Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
- [MDN - History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API)
- [Next.js - Dynamic Imports](https://nextjs.org/docs/app/building-your-application/optimizing/lazy-loading)
- [Google - JavaScript SEO Basics](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics)