🚀

スクロール連動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. スクロール監視フックの実装

// 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);
  }
}, []);

まとめ

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

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

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

参考リンク

GitHubで編集を提案

Discussion