0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-06-14

はじめに

モダンなポートフォリオサイトでは、スムーズなスクロール体験と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効果を両立させることで、ユーザーにも検索エンジンにも優しいサイトを構築できます。

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?