Documentation Next.js

はじめに

モバイルユーザーの増加に伴い、低速回線(3G回線や不安定なWi-Fi環境)でも快適に利用できるWebアプリケーションの需要が高まっています。Googleの調査によると、ページの読み込みに3秒以上かかると、53%のユーザーがサイトを離脱するとされています。

本記事では、Next.jsを使用して低速回線環境でも優れたユーザー体験を提供するための具体的な最適化手法を、実装コードとともに解説します。

この記事で学べること

  • Lazy Loading(遅延読み込み)の実装方法
  • 画像・スクリプトの最適化テクニック
  • プリフェッチとキャッシュ戦略の管理
  • SSR/SSGを活用したパフォーマンス向上
  • Service Workerを使ったオフライン対応

低速回線対応が重要な理由

低速回線対応は、以下の理由から現代のWeb開発において不可欠です。

  1. グローバル展開: 新興国市場ではまだ3G回線が主流の地域が多い
  2. モバイルファースト: 移動中や地下など、通信環境が不安定な場所での利用増加
  3. SEOへの影響: Core Web Vitalsはページ速度を重要な指標として評価
  4. ユーザー体験: 読み込み時間の長さは直帰率に直結

Lazy Loading(遅延読み込み)の実装

Lazy Loading(遅延読み込み) とは、ページの初回表示時には必要最小限のリソースのみを読み込み、残りのリソースは必要になったタイミングで読み込む手法です。

next/dynamicによるコンポーネントの遅延読み込み

next/dynamicを使用すると、コンポーネント単位での遅延読み込みを簡単に実装できます。

import dynamic from 'next/dynamic';

// 重いコンポーネントを遅延読み込み
// loading: ロード中に表示するフォールバックUI
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
  loading: () => (
    <div className="animate-pulse bg-gray-200 h-64 rounded-lg">
      <p className="text-center pt-24">グラフを読み込み中...</p>
    </div>
  ),
  // SSRを無効化(クライアントサイドのみでレンダリング)
  ssr: false,
});

// 使用例
export default function DashboardPage() {
  return (
    <main>
      <h1>ダッシュボード</h1>
      {/* ユーザーがスクロールして見える位置に来たときにロード */}
      <HeavyChart />
    </main>
  );
}

Intersection Observer APIを使った画像の遅延読み込み

画面外の画像は、ユーザーがスクロールして見える位置に来るまで読み込みを遅延させます。

'use client';

import { useEffect, useRef, useState } from 'react';

interface LazyImageProps {
  src: string;
  alt: string;
  className?: string;
  placeholderSrc?: string;
}

export function LazyImage({
  src,
  alt,
  className = '',
  placeholderSrc = '/images/placeholder.webp',
}: LazyImageProps) {
  const [isLoaded, setIsLoaded] = useState(false);
  const [isInView, setIsInView] = useState(false);
  const imgRef = useRef<HTMLImageElement>(null);

  useEffect(() => {
    // Intersection Observer APIで要素の可視性を監視
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          // 要素が画面内に入ったら読み込み開始
          if (entry.isIntersecting) {
            setIsInView(true);
            observer.disconnect();
          }
        });
      },
      {
        // 画面の100px手前から読み込み開始(先読み)
        rootMargin: '100px',
        threshold: 0.1,
      }
    );

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <div className={`relative overflow-hidden ${className}`}>
      {/* プレースホルダー画像(ぼかし効果付き) */}
      <img
        ref={imgRef}
        src={isInView ? src : placeholderSrc}
        alt={alt}
        className={`transition-opacity duration-300 ${
          isLoaded ? 'opacity-100' : 'opacity-0'
        }`}
        onLoad={() => setIsLoaded(true)}
      />
      {/* ローディングスケルトン */}
      {!isLoaded && (
        <div className="absolute inset-0 bg-gray-200 animate-pulse" />
      )}
    </div>
  );
}

画像とスクリプトの最適化

Next.js Imageコンポーネントの活用

next/imageコンポーネントは、自動的に画像を最適化し、適切なサイズとフォーマットで配信します。

import Image from 'next/image';

export function OptimizedGallery() {
  return (
    <div className="grid grid-cols-2 gap-4">
      {/* ファーストビューの画像はpriorityを設定 */}
      <Image
        src="/images/hero.jpg"
        alt="メインビジュアル"
        width={800}
        height={600}
        priority // LCP(Largest Contentful Paint)対象の画像に設定
        placeholder="blur" // ぼかしプレースホルダーを表示
        blurDataURL="data:image/jpeg;base64,/9j..." // 低解像度のBase64画像
      />

      {/* スクロールで表示される画像は遅延読み込み(デフォルト) */}
      <Image
        src="/images/product-1.jpg"
        alt="商品画像1"
        width={400}
        height={300}
        loading="lazy" // 明示的に遅延読み込みを指定
        sizes="(max-width: 768px) 100vw, 50vw" // レスポンシブ対応
      />
    </div>
  );
}

next/scriptによるスクリプト最適化

サードパーティスクリプトは、読み込みタイミングを制御することでパフォーマンスを改善できます。

import Script from 'next/script';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        {children}

        {/* afterInteractive: ページがインタラクティブになった後に読み込み */}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
          strategy="afterInteractive"
        />

        {/* lazyOnload: 全てのリソース読み込み後に読み込み(優先度低) */}
        <Script
          src="https://platform.twitter.com/widgets.js"
          strategy="lazyOnload"
          onLoad={() => console.log('Twitter widgets loaded')}
        />

        {/* worker: Web Workerで読み込み(メインスレッドをブロックしない) */}
        <Script
          src="/scripts/analytics.js"
          strategy="worker"
        />
      </body>
    </html>
  );
}

プリフェッチとキャッシュ戦略の管理

プリフェッチの制御

Next.jsはデフォルトでビューポート内のリンクをプリフェッチしますが、低速回線では逆効果になることがあります。

import Link from 'next/link';

export function Navigation() {
  return (
    <nav>
      {/* 重要なページはプリフェッチを有効に */}
      <Link href="/" prefetch={true}>
        ホーム
      </Link>

      {/* あまりアクセスされないページはプリフェッチを無効に */}
      <Link href="/terms" prefetch={false}>
        利用規約
      </Link>

      {/* 条件付きプリフェッチ */}
      <Link
        href="/dashboard"
        prefetch={typeof window !== 'undefined' && navigator.connection?.effectiveType !== '2g'}
      >
        ダッシュボード
      </Link>
    </nav>
  );
}

ネットワーク状態の検出と適応

Network Information APIを使って、ユーザーのネットワーク状態に応じた最適化を行います。

'use client';

import { useEffect, useState } from 'react';

interface NetworkInfo {
  effectiveType: '2g' | '3g' | '4g' | 'slow-2g';
  downlink: number;
  saveData: boolean;
}

export function useNetworkStatus() {
  const [networkInfo, setNetworkInfo] = useState<NetworkInfo | null>(null);

  useEffect(() => {
    // Network Information APIのサポートチェック
    const connection = (navigator as any).connection;
    if (!connection) return;

    const updateNetworkInfo = () => {
      setNetworkInfo({
        effectiveType: connection.effectiveType,
        downlink: connection.downlink,
        saveData: connection.saveData || false,
      });
    };

    updateNetworkInfo();
    connection.addEventListener('change', updateNetworkInfo);

    return () => connection.removeEventListener('change', updateNetworkInfo);
  }, []);

  return networkInfo;
}

// 使用例:ネットワーク状態に応じて画像品質を切り替え
export function AdaptiveImage({ src, alt }: { src: string; alt: string }) {
  const network = useNetworkStatus();

  // 低速回線またはデータセーバーモードの場合は低品質画像を使用
  const quality = network?.effectiveType === '2g' || network?.saveData ? 30 : 75;

  return (
    <Image
      src={src}
      alt={alt}
      width={800}
      height={600}
      quality={quality}
    />
  );
}

Service Workerによるキャッシュ戦略

next-pwaを使用して、オフライン対応とキャッシュ戦略を実装します。

npm install next-pwa
// next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  // 開発環境では無効化
  disable: process.env.NODE_ENV === 'development',
  // キャッシュ戦略のカスタマイズ
  runtimeCaching: [
    {
      // 画像のキャッシュ戦略
      urlPattern: /^https:\/\/.*\.(?:png|jpg|jpeg|webp|svg|gif)$/,
      handler: 'CacheFirst', // キャッシュを優先
      options: {
        cacheName: 'image-cache',
        expiration: {
          maxEntries: 100, // 最大100件
          maxAgeSeconds: 30 * 24 * 60 * 60, // 30日間
        },
      },
    },
    {
      // APIレスポンスのキャッシュ戦略
      urlPattern: /^https:\/\/api\.example\.com\/.*/,
      handler: 'NetworkFirst', // ネットワークを優先、失敗時はキャッシュ
      options: {
        cacheName: 'api-cache',
        expiration: {
          maxEntries: 50,
          maxAgeSeconds: 5 * 60, // 5分間
        },
        networkTimeoutSeconds: 10, // 10秒でタイムアウト
      },
    },
    {
      // 静的アセットのキャッシュ戦略
      urlPattern: /^https:\/\/.*\.(?:js|css)$/,
      handler: 'StaleWhileRevalidate', // キャッシュを返しつつバックグラウンドで更新
      options: {
        cacheName: 'static-cache',
      },
    },
  ],
});

module.exports = withPWA({
  // その他のNext.js設定
});

サーバーサイドレンダリング(SSR)と静的サイト生成(SSG)

適切なレンダリング戦略の選択

コンテンツの特性に応じて、最適なレンダリング方法を選択します。

// app/blog/[slug]/page.tsx
// 静的サイト生成(SSG)- ビルド時にHTMLを生成
export async function generateStaticParams() {
  const posts = await fetchAllPosts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function BlogPost({
  params,
}: {
  params: { slug: string };
}) {
  // ビルド時にデータを取得
  const post = await fetchPost(params.slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

// ISR(Incremental Static Regeneration)を有効化
// 60秒ごとにバックグラウンドで再生成
export const revalidate = 60;
// app/dashboard/page.tsx
// 動的レンダリング - リクエストごとにサーバーで生成
export const dynamic = 'force-dynamic';

export default async function Dashboard() {
  // リクエスト時にデータを取得
  const userData = await fetchUserData();

  return (
    <main>
      <h1>ようこそ、{userData.name}さん</h1>
      {/* ユーザー固有のコンテンツ */}
    </main>
  );
}

ストリーミングSSRの活用

React 18のSuspenseを使って、コンテンツを段階的にストリーミング配信します。

import { Suspense } from 'react';

// 重いデータフェッチを行うコンポーネント
async function SlowDataComponent() {
  const data = await fetchSlowData(); // 時間がかかる処理
  return <div>{data.content}</div>;
}

export default function Page() {
  return (
    <main>
      {/* 即座に表示される部分 */}
      <h1>ページタイトル</h1>
      <p>この部分はすぐに表示されます。</p>

      {/* 遅延読み込みされる部分 */}
      <Suspense
        fallback={
          <div className="animate-pulse bg-gray-200 h-32 rounded">
            データを読み込み中...
          </div>
        }
      >
        <SlowDataComponent />
      </Suspense>
    </main>
  );
}

ローディング状態の最適化

ユーザーに適切なフィードバックを提供することで、体感速度を向上させます。

// app/loading.tsx
// ルートレベルのローディングUI
export default function Loading() {
  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="space-y-4 w-full max-w-2xl p-4">
        {/* スケルトンスクリーン */}
        <div className="animate-pulse">
          <div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
          <div className="h-4 bg-gray-200 rounded w-full mb-2" />
          <div className="h-4 bg-gray-200 rounded w-5/6 mb-2" />
          <div className="h-4 bg-gray-200 rounded w-4/6" />
        </div>
      </div>
    </div>
  );
}
'use client';

import { useEffect, useState } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';

// ページ遷移時のプログレスバー
export function NavigationProgress() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const [isLoading, setIsLoading] = useState(false);
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    setIsLoading(true);
    setProgress(30);

    const timer1 = setTimeout(() => setProgress(60), 100);
    const timer2 = setTimeout(() => setProgress(80), 200);
    const timer3 = setTimeout(() => {
      setProgress(100);
      setTimeout(() => setIsLoading(false), 200);
    }, 300);

    return () => {
      clearTimeout(timer1);
      clearTimeout(timer2);
      clearTimeout(timer3);
    };
  }, [pathname, searchParams]);

  if (!isLoading) return null;

  return (
    <div
      className="fixed top-0 left-0 h-1 bg-blue-500 transition-all duration-200 z-50"
      style={{ width: `${progress}%` }}
    />
  );
}

パフォーマンス計測とモニタリング

最適化の効果を測定するために、パフォーマンスメトリクスを収集します。

// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        {children}
        {/* Vercel Analytics */}
        <Analytics />
        {/* Core Web Vitals計測 */}
        <SpeedInsights />
      </body>
    </html>
  );
}
// lib/web-vitals.ts
import { onCLS, onFID, onLCP, onFCP, onTTFB } from 'web-vitals';

type MetricHandler = (metric: any) => void;

export function reportWebVitals(onReport: MetricHandler) {
  // Cumulative Layout Shift(視覚的安定性)
  onCLS(onReport);
  // First Input Delay(インタラクティブ性)
  onFID(onReport);
  // Largest Contentful Paint(読み込みパフォーマンス)
  onLCP(onReport);
  // First Contentful Paint
  onFCP(onReport);
  // Time to First Byte
  onTTFB(onReport);
}

// 使用例
reportWebVitals((metric) => {
  console.log(metric.name, metric.value);
  // アナリティクスサービスに送信
  // sendToAnalytics(metric);
});

まとめ

Next.jsを使った低速回線対応のポイントをまとめます。

手法効果実装難易度
Lazy Loading初回読み込み時間の短縮
画像最適化転送量の削減
プリフェッチ制御不要なリソース読み込みの抑制
Service Workerオフライン対応・キャッシュ活用
SSG/ISRサーバー負荷軽減・高速配信
ストリーミングSSR体感速度の向上
ネットワーク検出適応的な最適化

これらの手法を組み合わせることで、どのようなネットワーク環境でも快適なユーザー体験を提供できます。重要なのは、実際のユーザー環境でのパフォーマンスを継続的に計測し、改善を続けることです。

参考文献

円